Changelog
All notable changes to Lerd will be documented here.
The format follows Keep a Changelog. Lerd uses Semantic Versioning.
[1.31.0] - 2026-07-29
The 1.31.0 line is largely about the routes into lerd and out of it. Installing became one job again: an interactive install performs its privileged work through lerd bootstrap rather than its own handful of sudo calls, and a package maintainer script gets a fully unattended route through that same code, so a machine ends up in the same state however lerd got onto it. Node stops being an fnm-only story, with an existing nvm drivable as the version manager and host workers resolving whatever node the machine actually has rather than dying on npm: command not found. Databases keep moving out of Go and into the store: create, drop, export, import and the snapshot commands are declared in a service preset now, which hands MongoDB the whole Databases tab and lets RustFS list its buckets in the same grid, while a dump taken from a hosted provider imports without anyone having to have known the right pg_dump flags at the time it was taken. A JetBrains project gets its data source pointed at the right database on the right port, and every tool lerd downloads is pinned behind a published manifest so an upstream release cannot break a fresh install overnight.
Sharing a site is where most of the rest of it went. The public tunnels the CLI already had grew a dashboard menu, a base domain set once serves every Cloudflare share on a stable hostname instead of a fresh throwaway URL, ngrok runs from its published image on a machine that never installed it, and a share started in a terminal shows up in the dashboard like one of its own. The reason to share something is usually to have someone else look at it, which is why a running dev server is now served on the site's own domain: a Vite app used to advertise a localhost address that meant nothing to the visitor, so a shared or LAN-opened page arrived unstyled. The tools lerd pins also became something to act on rather than only read, with the pending version on each System card as the button that applies it.
A review of the whole cycle sits behind the security section. Three of those findings let a checked-out project decide what ran on the machine that linked it, through values it commits in its own .lerd.yaml, and all three predate this cycle, so they reach back past 1.30.1. The same review also closed a number of gaps in this cycle's own new work before any of it shipped, and those are described inside the features they belong to rather than listed as fixes, since no released version ever had them. What is under Fixed is what a 1.30.1 install actually runs into. macOS gets a pass of its own, closing the gaps between what a terminal can resolve and what the launchd daemons can.
Added
- An assistant reaches a database engine's extensions, a service's other contents, and the Node manager (#1203). Three capabilities had no MCP tool at all. Postgres extensions were reachable only from the CLI, so an assistant could not see what an engine offers or add one before a dump arrives. Everything a preset declares that is not a database, the buckets a RustFS install holds, was reachable only from the dashboard. And the reference described the fnm and nvm split while offering nothing to act on it. The reference also now states what is deliberately absent, so an assistant stops looking: host tools are reported but applying an update is a CLI command, and sharing a site is not exposed at all, since putting a site on the public internet is the user's call.
- Definitions for the framework versions that still run on PHP 7.4 (lerd-env/frameworks#30). Definitions covered only the recent majors, so a project on an older release borrowed the nearest one, which describes a different version. Laravel 6 through 9, Symfony 4 through 6, Drupal 8 and 9, Statamic 3 and 4, CakePHP 3 and CodeIgniter 3 now have their own. These ship from the store, so they reach an existing install without this release.
- A site is shared through a public tunnel from the dashboard (#1116). The share button only ever started the LAN proxy, while the CLI could already tunnel a site through ngrok, cloudflared, Expose, serveo or localhost.run. The button now opens a menu on hover without giving up its one-click LAN toggle: a local network section mirrors the LAN state, and a public tunnel section starts any installed tool, with the same automatic pick a bare
lerd sharemakes and disabled entries carrying install hints for the tools that are missing.lerd-uiowns the tunnel process, building the same invocation as the CLI through a shared command builder, running the tool headless and waiting for it to print its public URL, which then sits next to the domain with the same hover QR as the LAN link. Tunnels are deliberately not persisted: they end when they are stopped or whenlerd-uishuts down, and are never resurrected on restart. lerd share --domainserves on a Cloudflare-managed hostname (#1106). A quick tunnel hands out a fresh random URL on every run, which is fine for showing someone a page and useless for a webhook target or an OAuth callback that has to keep working.--domainserves the site on a hostname whose DNS Cloudflare manages, creating a named tunnel, routing the record to it and reusing both on later runs so the URL never changes. Custom hostnames exist only for Cloudflare Tunnel, so the flag selects that tool on its own and outranks a default set with the newshare:tool, while combining it with a different tool flag is rejected rather than silently overridden. Credentials for a tunnel created on another machine are checked before any public URL is announced, since the failure otherwise surfaced inside cloudflared after lerd had already promised an address that was never going to work.- A Cloudflare share is served on your own base domain (#1215).
--domainfixes a quick tunnel's throwaway URL for a single run, at the cost of typing the whole hostname every time. A base domain settles it once: set one and every Cloudflare share is served on<site>.<base domain>through a named tunnel, with the label taken from the site's own domain rather than the folder the project happens to sit in. The dashboard asks the first time Cloudflare Tunnel is picked from the share menu, where a base domain can be typed or skipped for a quick tunnel, and ticking remember stops the question coming back whichever way it was answered, with the cog beside the entry reopening that dialog to change the domain later or clear it so the question returns.lerd share:domainis the terminal equivalent, and--domainstill wins for a single run. A worktree's subdomain flattens into one label, so a branch is served onfeat-login-myapp.example.com, because a Cloudflare certificate covers one level of subdomain and no more and the dotted form would fail TLS before it ever reached the tunnel. Named tunnels need cloudflared authorized once, which the dashboard cannot do because it has no terminal to run the browser login in, so it says what to run instead of hanging on a prompt nobody can see. The other half of this is that a share was only ever visible where it was started: alerd sharerunning in a terminal records itself for as long as it is up and clears the record on the way out, so the dashboard reflects it like one of its own and can stop it, a share killed outright has its record dropped once the process is noticed gone, and a site carries an icon in the list while it is shared, publicly or on the LAN, including when the share belongs to one of its worktrees. - ngrok runs from its published image when it is not installed (#1224). ngrok is the one tunnel tool with a published image, so lerd can run it under podman and a machine that never installed anything can still share through it. A container carries none of the host's ngrok configuration and ngrok refuses to open a session without a credential, so this needs an auth token: it is stored with
lerd share:token, overridden for a single run withlerd share --token, and set from the cog beside ngrok in the dashboard's share menu, and it authenticates an installed ngrok that was never given one of its own too. An installed tool always wins, since pulling an image is the slower route to the same URL, and the image only stands in when nothing is installed, where it ranks ahead of the signup-free SSH tools because storing a token is a deliberate choice of ngrok. With no binary and no token the menu says so and offers the token, instead of sending the user to an install page they do not have to visit. The token is a credential and is handled as one: saving it tightens the config file to owner only, it reaches both the container and the binary through the environment rather than the command line so it cannot be read offps, and the dashboard is told only whether one exists, never what it is. A container also does not die with the process that started it, because podman's supervisor is reparented out of the client's process tree and into its own cgroup, so the tunnel runs under a name of its own after the site and its branch: stopping it removes the container by that name rather than signalling a process, every start sweeps whatever a previous run left behind, a name left over from a crash is replaced rather than blocking the next start, and a running tunnel shows in the dashboard's resource usage alongside lerd's other containers. - A dev server is served on the site's own domain (#1217, #1222). A dev server advertises its own address, so a Vite app renders asset URLs pointing at localhost on a port only this machine can reach. Share the site through a tunnel, open it from the LAN, or front it on a worktree subdomain, and the visitor's browser resolves those URLs against their own machine and the page arrives unstyled, which the share proxy could not fix because it only rewrites the site's own domain and the dev server's URLs never carry it. Everything a supported dev server emits now lives under one base prefix that the site's own vhost proxies to it, so a single nginx location carries the assets and the hot-reload websocket alike and both travel on whatever hostname the visitor actually used, with nothing left to rewrite because the client derives its host, port and protocol from the URL it was loaded from. None of this is declared anywhere: a host worker qualifies when the project has the tool installed and the command starts it directly, following one level of package-manager script indirection, while a command that only reaches the tool through a runner is deliberately not matched since the appended flags would land on the wrong process. Nothing in the project is edited either. lerd writes a generated config next to the project's own, importing it and merging in the base, origin and allowed hosts for serve only, rewritten on every start since a worktree seeds
node_modulesfrom its parent by reflink and would otherwise inherit the parent's domain, and a project that tracks the generated path in git rather than ignoring it, or that has no config file for the tool, keeps its dev server exactly as it was. Every value reaching that file is encoded as a literal rather than interpolated between quotes the template supplies, and a quote is refused in a hostname when the site is registered, so the sink is not left as the only guard. The port has to be pinned because the vhost proxies to it, so it is kept clear of other sites, of the site's own worktrees and of whatever else the machine happens to be holding, re-picked when something has since taken it, and each worktree pins its own and takes its origin from its own subdomain. Plugin middleware that registers itself ahead of the tool's base handling only answers unprefixed and would 404 on URLs it advertised itself, so nginx retries any 404 under the prefix once with the prefix removed, which covers the plugins nobody has written yet. - nvm drives Node alongside the bundled fnm (#1112). Node version management was fnm and nothing else, so a developer who already keeps their versions in nvm had lerd download a second manager and install its own copies beside theirs. A
node.managersetting now chooses between the two behind one interface, and every surface that touches Node, the CLI, host workers, shims, MCP, the TUI picker and the idle Vite builds, goes through the active manager rather than calling fnm directly. An install that finds nvm asks once whether lerd should manage Node, where yes keeps the bundled fnm and no hands Node back to the nvm that is already there, and the choice is switchable later withlerd node:manageror from the dashboard's Node page. lerd treats a user's nvm as read only: it never installs into it and never rewrites itsdefaultalias, keeping lerd's own default in lerd's config instead, and it leaves thenode,npmandnpxPATH shims out entirely under nvm, since a shim ahead of nvm madenvm usere-enter it and hang. - Package-manager installs set themselves up without prompting (#979). Debian and Ubuntu users install from a PPA, where a maintainer script runs as root and cannot answer the sudo prompts a normal
lerd installrelies on.lerd bootstrap --systemperforms the root-level, machine-global steps, the unprivileged-port sysctl, systemd linger and the DNS sudoers rule;lerd install --unattendedruns the rest with no questions, defaults to managed.testand generates the mkcert CA needing no sudo; andlerd bootstrap --trust-cainstalls that CA into the system trust store afterwards. The unattended path answers the Node question by detection rather than by reading shim state, which on a first install describes nothing, so a package install provisions Node instead of recording it as unmanaged and leaving the first command that needs a runtime to fetch one. Every unattended-only step is gated behind the flag, so an ordinary install is unchanged. The systemd unit templates no longer hardcode~/.local/bin/lerd, so a daemon starts from wherever the package put the binary,lerd updaterecognises a binary under/usrand points at the package manager rather than fighting apt, and the trust step detects the anchor directory the way mkcert does across the Fedora, Debian, Arch and openSUSE layouts, reporting where the CA lives on a distribution with no writable store rather than failing the maintainer script. The user name the sudoers drop-in is written for becomes the first field of every rule in that file, so it is validated and the rendered file is put through visudo before it is installed, since a space alone is enough to produce a drop-in sudo rejects and lose the DNS grant it was meant to install. - A JetBrains project points at its own lerd database (#1134). An IDE cannot work out a site's database on its own, and the reason is lerd's: the site's env file names the container and its internal port, which is correct inside the network and unusable from the machine, where the same engine answers on loopback and whatever port it was published on. lerd now maintains one data source entry in the project's JetBrains XML, scoped to the project's own database rather than the whole server, written on link, refreshed by
lerd envand the group commands, and removed by unlink, with the port read fresh each time so a second engine of the same family shifting ports cannot leave it stale. It detects and never creates: a project with no.ideadirectory is left alone, every data source lerd does not own comes back byte for byte, an entry whose database the project has stopped using is dropped, and a connection the user already wired to that database is theirs to keep. A sync that decides to change nothing writes nothing, rather than creating an empty credentials sidecar in a project it just left alone and moving the other file's mtime for no reason. Setide_data_source: falseinconfig.yamlto leave IDE files alone entirely. - An engine's entities and actions are declared in its preset (#1117, #1120, #1219). The Databases tab read its list from a preset, but everything else it did, create, drop, export, import and the snapshot commands, was a hardcoded mysql, mariadb or postgres switch in Go. An
introspectblock now declares entities, each naming the command that lists it with typed columns and the commands behind every action it supports, including which are destructive so the UI confirms first. One generic runner validates the entity name before substituting it into the declared command and derives the capability flags from what the engine actually declares, so a store engine gains any of these operations through a publish with no binary release. MongoDB picks up the whole Databases tab this way, and a service holding something other than databases declares it the same way: RustFS lists, creates and deletes buckets through an ephemeral client container, exporting a bucket as a tar.gz of its objects and loading it back from either form. The MCP db tool and the CLI export and import paths run through the same declared commands, which also fixes the MCP create path skipping declared extensions and its hardcoded container names making an alternate instance unreachable. The dashboard asks the engine rather than a list too: a service qualifies for the Databases tab when it belongs to a family lerd wires as a project database, as before, or when its preset declares databases it can enumerate, so an engine published in the store with a perfectly good introspect block no longer waits for a lerd release to become visible. What lerd may offer as a project's primary database is deliberately left alone, since an analytics column store has no business being offered there. Import honours the client image an engine declares the way export always did, so an engine whose own image ships no tooling can be loaded as well as dumped. - A dump from a managed provider imports without the flags nobody set at dump time (#1132). A dump taken from a hosted Postgres or MySQL arrived with hundreds of errors, none of them the user's mistake. One measured here reported 320, of which 80 were ownership: the dump assigns objects to roles that exist at the provider and nowhere else, and lerd's engines run a single admin role every preset points sites at, so those statements can never apply. The mysql families carry the same thing as a
DEFINERclause, and worse, since running as root the object is created and only fails when something uses it. Both are filtered on the way in, which is what--no-owner --no-privilegesdoes at dump time except the user does not have to have thought of it. Row data is never rewritten: a postgresCOPYblock passes through byte for byte between its header and its closing marker, and on mysql only DDL lines are touched, so a value holding the word survives. What was filtered rides back on the report and shows beside the errors, because a dump lerd quietly rewrote must never read as one that arrived clean. Loading into a database that already holds the objects is the other half and no filter can fix it, so import takes an empty-first choice in the dialog and--freshon the CLI. On the dump that prompted this the two together took it from 90 errors to 11, with 44 of its 45 tables landing exactly. - An engine declares the extensions its image can create (#1137). A provider dump usually does not carry its extensions, because
pg_dumpskips the ones the connecting user does not own, so a vector column arrived with noCREATE EXTENSIONanywhere, the table was rejected, everything depending on it failed, and nothing in the load named the extension that would have fixed it. A postgres engine now declares what its image can create along with the types each extension provides, so no extension name lives in Go. An entry marked always is created wherever lerd creates a database, so a database dropped and recreated by an import or a snapshot restore comes back whole rather than missing what the site was built on, and the rest wait until a dump names one of their types, which is how a geometry column brings PostGIS with it without every database on the machine paying the 15 MB for it. The same declaration backsdb:extension listanddb:extension addfor when an extension is wanted before any dump arrives, and whatever was created rides back on the import report and shows on the card, since a database must never gain something without saying so. - A service dependency is satisfied by an installed drop-in (#1114).
depends_ontargeted a literal service name, so RedisInsight stayed blocked on a Valkey-only host and phpMyAdmin passed preflight against MariaDB and then failed to start against a mysql that was not there. Each entry now resolves to the named service or to any installed service whose family orenv_rolematches, and lerd starts that satisfier, preferring a same-family one over anenv_roledrop-in and a running one over a stopped one. A drop-in only counts when the consumer can actually wire it, so an admin tool cannot install green and then fail to connect, and an unmet dependency lists the store-declared alternatives rather than naming one engine. Connection hosts follow: an environment value naming the canonicallerd-<dep>host is rewritten to the satisfier at quadlet generation, so a preset keeps its plainlerd-redisin the store yaml and lands onlerd-valkeywhere Valkey is what satisfies redis, which also means a binary predating this parses the preset unchanged. That rewrite stops at a name boundary, so an environment value naming a versioned install of the same engine is not caught as a bare substring and left pointing at a host that does not exist. Matching dependents by family andenv_rolerather than by name alone also means a service whose own family satisfies one of itsdepends_onentries would be its own dependent, so the stop cascade skips itself instead of recursing without end.depends_onstays a plain list of names, so no on-disk YAML needs migrating. expand_envwrites one numbered variable per family member (#1147).discover_familyandrepeat_familyboth collapse a family into one comma-joined value, which fits phpMyAdmin'sPMA_HOSTSand not a tool that reads numbered sets. RedisInsight is the case in point: it can preconfigure any number of databases throughRI_REDIS_HOST_1style groups, so a preset had to hardcode a single connection and an install running Valkey, or both engines side by side, opened the UI with those containers missing. A new top-levelexpand_envfield writes one variable per running member, the declared key suffixed_1to_N, expanding placeholders for the container hostname and the service name. It sits at the top level rather than insidedynamic_envon purpose: preset YAML parses non-strictly, so an older binary drops an unknown top-level field and keeps serving the preset's unsuffixed static keys as the single-connection fallback, where an unknown directive insidedynamic_envwould have made every deployed binary refuse to start the service. A binary that does resolveexpand_envdeletes the unsuffixed key as it writes the numbered set, so the two never coexist in a rendered quadlet. Unknown directives are warned and skipped now rather than failing quadlet generation outright, which protects whatever comes next.- Host tools are pinned behind a published manifest, and every download retries (#1146). A momentary CDN hiccup could abort a whole install, since every binary lerd fetches went through a one-shot request with no retry and no timeout. Composer, fnm, mkcert, phpantom_lsp and the self-update archive now share a helper that retries transient failures with a short backoff and cancels a stalled transfer into the retry path instead of hanging forever, and
install.shgained the matching curl and wget flags. What a fresh install downloaded also depended on the day it ran, because fnm and mkcert came through GitHub'sreleases/latestredirect and composer through its stable channel, so an upstream release could break every new install overnight. The versions are pinned in a manifest now, embedded as the offline fallback and fetched from the repository with a daily cache, so a broken pin is fixable for every install with one commit and no binary release, and a scheduled job checks upstream daily and opens the bump PR. The managed tools are visible and updatable too: each download stamps a version sidecar,lerd statusshows a Tools section, the web UI gains a System Tools page and a dashboard health row, MCP's diag status reports the same list, andlerd tools:updatere-downloads exactly the tools whose installed version differs from the pin. Because the manifest is fetched from a branch rather than shipped in a release, so it reaches every install without going through one, a pin is honoured only when it points at a host lerd fetches tools from, may carry a sha256 the download is checked against, and lands in a temporary file moved into place only once complete, so a rejected or truncated download cannot replace a working binary. Composer reports a real version throughout: it is a phar and lerd has no host PHP to run it with, so the version is read out of the phar itself and stamped, rather than being known only when lerd was the one that downloaded it. - A host tool is updated from the dashboard (#1210, #1211). Reporting Composer, fnm and mkcert against the versions lerd pins and flagging the ones that differ still sent you to a terminal for a single command. The pending version on each card is the button that applies it now, and each card owns its result, which is what keeps this per tool rather than one button for all three: a download that fails its checksum is refused deliberately, and the card that asked has to name that tool rather than show a generic failure for the set. The pins come from a manifest cached for a day, so a newly published one could take that long to appear on its own, and the Tools page gains a check that re-reads it immediately. One check covers every card because the pins live in a single manifest, and a fetch that cannot reach the endpoint keeps the cached pins rather than falling back to the embedded ones, so being offline never reads as nothing to update. A tool that falls behind its pin also raises an update_available notification, the way a service and a PHP base image already do, riding the same status snapshot, firing once when a tool first goes stale and seeding itself quietly on the first snapshot of a process so a machine that is already behind does not announce everything at startup. The route replaces executables on the host's PATH, so it is restricted to loopback alongside the terminal and site-link routes rather than sitting behind LAN credentials alone.
- A republished PHP base image surfaces as an update on the version (#1172). Base images are republished to the registry under the same recipe-hash tag whenever the upstream PHP image changes, and the client compared only recipe and custom-set hashes, so refreshed content under an old tag was invisible and an upstream PHP or Alpine fix reached an install only at the next event that moved a local hash, which for someone who never forces a rebuild is never. Every build now stamps the registry digest of the base it was built from, read straight from the registry so a refresh inside the cache window cannot stamp a digest the pulled base has already moved past, and a manifest lookup with no pull compares that against what the tag serves now, cached for six hours including failures so an unreachable registry is asked once per window. The status path only ever reads the cache and warms a cold entry in the background, so the dashboard never waits on the network and an offline machine stays quiet rather than reporting a false update. The version card carries the flag, the detail menu promotes the rebuild once the base has moved and always offers a manual check, doctor reports a stale base as a warning with the rebuild as its fix, and MCP's
php_listflags it too. On the publishing side an upstream refresh now also rebuilds the hashes the two most recent stable releases resolve to, where before it froze every earlier tag at its last build and left a user one release behind pulling an unpatched base whatever they did. - The tinker split flips direction and goes full screen (#1115). The tab was a fixed 50/50 side-by-side split, which cramped long scripts that wanted height and wide dumps that wanted width. Two controls in the header fix that: a toggle between side by side and stacked, and a full screen button that expands both panes to fill the viewport. Escape exits full screen, the chosen direction persists across sessions, and the toggle is hidden on mobile where the panes already stack. Full screen names the site domain, and the branch when a worktree is selected, since the site header is hidden there.
- The php.ini tab edits the version and the shared file side by side (#1121). It edited one file at a time behind a scope dropdown, and the two files work together, the shared one setting the baseline and the version one overriding it, so reading one while editing the other meant switching scope and reloading the editor, which dropped whatever was in the buffer. The tab splits vertically now, the version's own ini on the left and the shared ini on the right, stacking below the large breakpoint. Each pane owns its own load, dirty state, save, revert, reset and backup restore, so an action on one cannot touch unsaved edits in the other.
Security
- A project decided what ran on the machine that linked it (#1203). Three values a repository controls through its committed
.lerd.yamlreached a file lerd generates without being examined. Anode_versionreached the command line of a host worker unit, taken unquoted by fnm and interpolated into a single-quoted message by nvm. Acustom_workersentry'slabel,restartandscheduleeach reached a line of that unit, where only the command was ever checked, and systemd executed anExecStartPreone of them added. Adomainsentry reached the vhost'sserver_name, where a semicolon and a pair of braces close the server block and open another, and nginx accepted the result as valid, so a project served a block of its own on a hostname it chose. Values are now checked where the file is generated rather than field by field, so a field added later is covered without anyone having to remember. Paths are treated separately, since they reach the template quoted and a directory legitimately named with a semicolon still has to serve. All three predate this cycle. - postcss and dompurify carried published advisories (#1205). postcss allowed a path traversal through its previous-source-map auto loading, reaching both the dashboard build and the docs site, and dompurify let a custom element bypass
afterSanitizeElements, reaching the dashboard through the Monaco editor behind the tinker and config editors.
Changed
- Every project is registered through one policy-driven path (#1151, #1152, #1158). Registering a directory as a site was implemented three times: the full one behind
lerd link, a thinner copy the parked-directory watcher used, and a third inside the MCP server that inlined the container and PHP paths. They had drifted badly. The MCP copy had no name deduplication, no domain-conflict fallback, no PHP clamping and no worktree check, and the park copy read nothing from.lerd.yamlbeyond the domain list, so a project committing a public directory, a PHP version or its own runtime was registered as though it had said nothing. A linker package now owns the decision half and returns a plan a second step carries out, and what separates the callers is a policy rather than separate code: a link you type may prompt, write the version pin files, install services, issue a certificate and supervise a dev command the project declares, while an unattended one reads the same committed configuration and does none of that. Parking also writes each site's vhost and unit and performs the reloads that publish them once for the whole batch rather than per project, so a tree of fifty projects takes three seconds where it took ninety-two, and one of three hundred takes sixteen. A project declaring its own container, host proxy or per-site runtime is reported as skipped rather than quietly registered as plain FPM, since provisioning it means building an image or running a command the repository wrote. - The N+1 warning always names where the duplicate queries ran (#1171). The notification named a route only when the query events carried an HTTP request, so every CLI invocation, artisan command, tinker session and scheduled task fell back to "Ran a similar query 3x in one request", which left the most common console cases with a warning and nowhere to start looking. The bridge now stamps a command context on every CLI-SAPI process, the script basename plus its arguments, capped so a long argument list cannot bloat every event and with any single value over 32 characters elided to its flag so a tinker one-liner does not put its whole snippet in the notification body. The warning picks the most specific label available, worker, command, then request, and the dedup key gained the same arm, since otherwise every console invocation shared one key and only the first artisan command of a session could ever warn.
- The tray menu groups into submenus and summarises the per-site workers (#1197). The services and PHP sections each carried their own fixed pool of menu items, a set of parallel arrays mirroring the rendered state, and a bank of click handlers that re-derived what to run from those mirrors, so the dot a service was drawn with and the command a click sent were decided in two different places. Both sections build rows carrying their own title and command now, so one generic list owns the pool, the redraw and the dispatch, and what a row does can no longer disagree with what it says. Entries past the pool size used to vanish without a trace and the last slot reports how many are not shown. Both lists move into submenus, taking the top level from around thirty five rows to twelve on a machine with the full stack installed, with the parent rows carrying the running service count and the current PHP default so the summary reads without opening anything. The status block gains a line summarising the per-site workers, which the menu filters out of the services list, warning from the worker health endpoint rather than from arithmetic on the running total, since a stopped worker is normal on a paused or idle-suspended site.
Fixed
- A project older than any framework definition was refused the PHP it requires (#1202, #1203). Definitions exist for the recent majors, so an older project borrows the nearest and is marked as guessed. The version switch clamped to that borrowed range anyway, so
lerd isolate 7.4on a Laravel 8 application answered that 7.4 was not usable and moved it to the newest PHP installed, while linking the same project declined to clamp at all, leaving the two disagreeing about it. A definition now governs only when it is the real one for the detected version, and otherwise the project's own composer requirement decides, which also closes the case where a guessed definition meant no range at all and a project could be handed a PHP it cannot run. Alternation is read in either spelling: composer accepts a single pipe and the projects this matters for use it, Laravel 8 requiring^7.3|^8.0, so the whole constraint was read as one term that nothing satisfied and detection fell through to its minimum, pinning 7.3 on a host running 8.5. - Every directory-scoped command dead-ended inside a git worktree (#1199). A worktree inherits its parent's registration and is deliberately never registered as a site of its own, but the shared resolver behind those commands looked a directory up by exact registry path, so inside a checkout it always missed. The command then offered to link the directory, link correctly refused because it is a worktree, and the caller reported "no site registered for this directory, run
lerd linkfirst", advice that can never succeed no matter how many times it is followed, taking outshare,open,domain,env,worker,runtime,stripeandxdebug pauseat once. The resolver falls back to the owning parent and the branch the checkout is on now, ahead of the link prompt. Commands that address a domain act on the branch rather than the parent:lerd sharetunnels the worktree's own subdomain, which needed a branch dimension on the tunnel registry so a worktree tunnel neither replaces the parent's nor is stopped along with it, with the Cloudflare named tunnel keyed to the branch too so routing a hostname to a worktree cannot take over the one the parent uses, and the dashboard share menu, which hid its whole public tunnel section while a worktree tab was active, offers it and acts on that branch.lerd lan:shareandlan:unshareresolved through a site name derived from the checkout directory and so assigned a LAN port to a site that does not exist, and drive the per-branch proxy that was already implemented now. Underneath all of it the worktree lookup compared raw paths where the registry lookup has always compared canonical ones, so on macOS and on ostree hosts, where a path is spelled two ways through a symlink, a checkout was not recognised as a worktree at all and linking inside one registered it as a separate site rather than refusing. - A secured site shared through ngrok or Expose sent the visitor to a
.testdomain (#1221). Those two were the tools aimed straight at nginx, with ngrok rewriting the Host header itself so the vhost would match, and for a secured site that is exactly where the public hostname goes: nginx answers the first request with a redirect to https on whatever Host it was given, which is the site's own.testdomain, and the visitor lands somewhere that only resolves on the developer's machine, while pointing ngrok at the HTTPS port instead fails on the local mkcert certificate. Both go through the same local proxy Cloudflare and the SSH tools have always used now, which sets the Host nginx routes on, dials a secured site without tripping on its certificate, and rewrites the site's own domain out of redirects and asset URLs so the tunnel host survives. That rewrite had a gap of its own: it swapped the domain only where the URL appeared in its plain form, and JSON escapes its slashes, so an absolute URL embedded in a page payload or returned from an XHR reached the browser still pointing at a domain nobody else resolves. An external redirect does not always travel in a Location header either, since a framework can hand its own client one through a header of its own, so Content-Location and X-Inertia-Location are rewritten alongside it, and a rewritten URL now comes back over https rather than keeping the scheme it had locally, because the tunnel is always TLS and a plain-http URL on a page served over it is refused as mixed content. On macOS the container route needed the other half of it: the ngrok container ran with host networking so it could reach the local proxy on loopback, which holds on Linux where the container shares the host's own network namespace but not on macOS where podman runs inside a VM and the container lands in the VM's namespace, where nothing is listening, so every request came back as ngrok's own 8012 error. - A link damaged the framework definition it found, and could not run without a terminal (#1144, #1154, #1155, #1156, #1157). A project that commits its own framework definition could not be linked from anywhere without a terminal, because the prompt asking which definition to keep drove a TUI form with no fallback and failed with "could not open TTY", which took out the dashboard and the MCP action for any such project. That question already had a conservative answer, keeping both sides as they are, so that is what a caller with nobody to ask gets. Worse, detecting a framework wrote to the store: a project's committed definition was saved over the installed one on any call including calls that were only reading, and a project commits only the fields it cares about, so a partial copy left the store entry partial and a copy carrying no detection rules left the shared definition with none, after which nothing detected that framework for any project on the machine. A published definition is left alone now and its differences are the conflict prompt's to resolve, while a framework a project defines itself still propagates, because there the repository is the source of truth. Re-linking a secured project with no
.lerd.yamlalso dropped it back to HTTP, since an absent file reads exactly like a file asking for HTTP, so a link only ever turns HTTPS on now. The dashboard reported a link as a clean success when the environment step failed, and the database row in the summary read the backup taken moments earlier rather than the file just written, so a project configured for one database was reported as using the one it replaced. - Every install route applies its root-level setup through
lerd bootstrap(#1193). An ordinary install did its privileged work through a handful of separate sudo calls while the deb package applied the same settings as root throughlerd bootstrap, two implementations of the same four steps that had already drifted apart. The interactive install re-executes itself assudo lerd bootstrap --systemnow and hands the mkcert system trust tosudo lerd bootstrap --trust-caonce the CA exists, so a machine ends up in the same state however lerd got onto it and all of the privileged work happens up front, which means someone who cannot use sudo finds out immediately rather than several minutes in. A host where the re-exec cannot run falls back to the old per-step path, and macOS keeps the behaviour it had. Uninstall gains the other half of it: the CA that bootstrap installs is named for lerd while uninstall removed the CA by callingmkcert -uninstall, which only recognises the filename mkcert itself wrote, so nothing ever deleted lerd's anchor and uninstalling left a trusted root certificate on the machine permanently. It is removed throughbootstrap --untrust-canow. - Uninstalling left the tray binary behind (#1230).
lerd uninstallremoved the binary it was running from and nothing else, while the shell uninstaller had always removed the tray alongside it, so the two routes disagreed about what an uninstall is. A tray can be started from a desktop entry rather than a unit, so what was left was not just a stray file but a lerd that could still be launched after the uninstall that was supposed to remove it. Both binaries go now, and a package-managed install still keeps its hands off both, since deleting a file out of a package's own file list leaves that manager believing lerd is installed. - The launchd daemons could not resolve what a terminal can, and Homebrew installs pinned themselves to a stale path (#1213). launchd hands lerd-ui and lerd-watcher a PATH of
/usr/bin:/bin:/usr/sbin:/sbin, so a tool installed with Homebrew is invisible to them while it works fine in a terminal. The dashboard's share panel reported ngrok, cloudflared and Expose as not installed on every Mac that has them and the auto pick fell through to the ssh fallback, and the same blind spot decided which Node a host worker unit runs, so a unit written by the CLI resolved the Homebrew node while the same unit written by the dashboard or the watcher fell through to whatever nvm install happened to be lying around, which means a worker could run modules that lerd npm installed under a different Node, and a machine whose only Node came from Homebrew had its workers refused outright even though the unit's own PATH would have found it. Host binaries resolve through one place now, PATH first and then the install prefixes a daemon never sees, and the resolved path is what gets executed, since the binary has to be found at exec time too. A public tunnel could also outlive the process that owns it: systemd kills the control group and Pdeathsig covers a non-systemd run, but macOS has neither, and a tunnel is deliberately put in a process group of its own so stopping it takes the tool's children with it, so killing lerd-ui left the site public with nothing in the dashboard left to stop it. Tunnels are stopped from the shutdown handler now, and because SIGKILL cannot be caught, what is running is written down and reaped at the next start, signalling a pid only while the process under it is still the one that was recorded. Homebrew installs get three more: the daemon units pinned themselves to the versioned Cellar path, which the next brew upgrade deletes, so they keep Homebrew's own symlink instead;lerd updateandlerd uninstalltreated any binary under/usr/localas owned by a Linux package manager and offered an apt or dnf command that does not exist on a Mac, so those prefixes only count on Linux now and Homebrew getsbrew upgradeandbrew uninstallwording of its own; and an nvm installed with brew went undetected, because brew keepsnvm.shunder its own prefix and has the user leaveNVM_DIRholding only the versions. Finallylerd install --unattendedis refused off Linux, since it skips the sudo-gated steps becauselerd bootstrapapplies them as root around it, and bootstrap is Linux-only, so on macOS the flag quietly produced an install with no resolver grant and an untrusted CA. - Host worker units could not find a Node that lerd does not manage (#1143). When lerd does not manage Node, a host worker unit runs its command against a rebuilt minimal PATH, so a node living wherever a version manager put it, or under snap or linuxbrew, was invisible at runtime and every npm-based worker died on start with
npm: command not foundeven though node worked fine in the user's terminal. The generators resolve where node and npm actually live before writing the unit, first on PATH while skipping lerd's own shims, then in the version manager layouts the installer already probes, then the static snap and linuxbrew directories, and bake that directory into the unit's PATH on Linux and into the launchd guard script on macOS. A location only wins outright when it covers npm too, so a bare distro node without npm cannot shadow a version manager install carrying the full toolchain, while a node-only directory is kept as a last resort sonode server.jsstyle host-proxy commands keep running. When nothing resolvable is found and the command actually needs the toolchain, the worker is held back with an actionable message instead of crash looping. - A changed worker unit was started rather than restarted (#1150). Change the dev command a host-proxy project declares in
.lerd.yaml, re-link the site, and lerd reported the dev server as started while the process still running was the old command. The start path rewrites the unit, reloads the daemon, re-enables and then starts, which is a no-op on an already-active unit, so the running process was never replaced. A unit that changed and is already active is restarted now, and only when it actually changed, so an unchanged command does not churn a healthy process. - A PHP version typed as
php84was stored verbatim and broke every name derived from it (#1173).lerd useaccepted it as given and wrote it straight into the global config, after which the pre-built base resolved to a repository the registry answers with 403 and the local fallback tried to pull an image tag that does not exist either. The command still reported success, so the install looked fine until the next image build failed. Version input goes through one normalizer now that reduces common spellings to major.minor, sophp8.4,84and8.4.7all become8.4and anything that does not resolve to a supported version is rejected up front with the supported list in the error. It applies everywhere a version enters, and loading the global config repairs an already stored bad default in memory, so an install wedged by an earlierlerd useheals on the next command instead of requiring the user to know to run it again with the right spelling. - A store preset stayed stale in a running
lerd-ui(#1182). Parsed presets are memoised in a cache only invalidated from the process that saved them, which holds for the embedded bundle and not for store presets, sincelerd install,lerd service updateand the daily refresh all rewrite those cache files from their own CLI process and a running daemon never noticed, serving the stale parse until the next restart. The cache entry records the file's mtime and size now and re-parses when either changes, and a file appearing for the first time invalidates naturally, which is the fresh-install ordering where the daemon starts before the store files are written and used to keep answering from the embed for the rest of its life. - Custom services inherited the host's
/etc/hosts(#1105). Without an explicit mount podman derives a container's hosts file from the host's own, so whatever a developer keeps there is inherited by every service container, and a line mapping a service name to loopback shadows the container name the DNS would otherwise resolve. phpMyAdmin, pgAdmin and mongo-express all connect by container name, so they reached their own loopback and failed with a connection refused. PHP-FPM containers already mounted a lerd-managed file, so the two paths were inconsistent. Every custom service mounts the managed file now, pre-created before the quadlet is written since podman creates a directory at a missing mount source, and a service resolves its peers through container DNS rather than that file. - Resetting a non-default version's php.ini left its FPM unable to start (#1124, #1125, #1126). Every FPM quadlet bind-mounts the per-version user ini unconditionally, so once the reset deleted it podman refused to start the container with a statfs error and systemd restart-looped it until the start limit was hit. The reset re-seeds a commented template now, which is what "no per-version override" looks like on disk and keeps the mount valid, the same thing the shared file and the FrankenPHP site files already got. It hid on whichever version is the default, because
lerd startrewrites that version's quadlet and re-seeds along the way, while any other installed version stayed dead until something happened to touch it. A reset whose restart fails now puts the contents back and applies again rather than leaving the version down with its settings deleted, both confirm dialogs raise a failure through the notification surface so a PHP version being down outlives the dialog that reported it, and the save and reset buttons name the restart while it runs instead of saying "Saving" through a multi-second wait. - An import lost every error past the fifth, and a gzipped dump was fed to the engine raw (#1130, #1133, #1135). A dump replayed over a schema that already had objects came back with 320 errors, of which the modal showed five and closed on a count of what it would not show, so everything past the fifth complaint was unreachable and the fifth is rarely the one that explains the load. The report keeps up to 500 distinct complaints now, which covers a dump that fails once per object, and the modal renders the full set. Separately a
.sql.gzupload came back as a success carrying three complaints nobody could act on and left the database untouched, because the upload went straight to the engine client, which reads plain SQL, so the gzip header choked it and it exited clean. The stream is unwrapped before it reaches the client, in the one place the web upload, the CLI and the MCP tool all read. Exports also carry what snapshots have always carried: postgres dumps clean with--if-existsso a colleague who has already run their migrations does not drown in collisions, and the mysql families include routines, triggers and events, whichmysqldumpleaves out by default and which therefore never left the machine. - Dropping a postgres database lost a race with the site's own connection pool (#1140). Emptying a database before an import failed whenever the site was running, because the engine refuses to drop a database any session still has open. The drop already terminated other sessions first, so this was a race rather than a missing step: that session is the site's own pool, which reconnects the instant it is closed and is back before the drop lands, which made the two-step form fail precisely in the case where someone reaches for the option. One statement closes the sessions and drops with no gap to reconnect into, with the two-step form kept for servers too old to have it. Snapshot restore recreates the database through the same function and was losing the same race.
- The COPY cascade went uncounted on postgres 18 (#1138). When a
COPYblock has no table to load into its rows are read as SQL and the engine complains once per line, and that flood is how the shape of a failed import is recognised, counted and folded into one entry so it cannot crowd out the statement that caused it. Dumps written bypg_dump18 open with a restrict line, in which mode psql refuses backslash commands, so the rows that used to report an invalid command now say backslash commands are restricted and the tally recognised only the older phrasing. On a dump measured here the import reported 6 errors where 48 had gone past, with the 42 carrying the shape of the failure invisible. Both phrasings are the same cascade now. lerd newlooked composer up on the caller's PATH (#1153). It failed with "executable file not found" on a machine that has only lerd installed, even though lerd ships composer and running it through lerd in the same directory worked. lerd's composer is a shim in its own bin directory, so the old path worked only in a shell that had exported that directory and failed in a script, a systemd unit, an editor terminal that never sourced the profile, and the MCP server. Every create command in the framework store starts with composer, so this covered all of them, and the surrounding code already assumed the container, creating the target's parent and mounting it before scaffolding. A create command beginning with composer runs the bundled archive through the project's PHP container now, while one naming anything else still reaches the host binary, since a framework is free to declare something composer cannot provide.lerd newrefused a framework the store publishes but the machine had never installed (#1162). It resolved the framework through the installed-only lookup, so a definitionlerd framework searchfinds was refused with an unknown framework error. Linking already fetches an uninstalled definition from the store, and scaffolding is the one command whose whole job is starting a project you have never built before, so it is exactly where the definition will not be local yet. The resolver falls back to the same store fetch linking uses, saves the fetched definition and re-resolves, for both the CLI and the MCP scaffold action, and a name the store does not publish keeps the original refusal.lerd newscaffolded under a PHP version the framework does not support (#1165). The create command ran from the new project's parent directory, which is empty, so version resolution fell through to the machine default. Scaffolding a framework whose supported range tops out below that default left composer rejecting every candidate for requiring a PHP the run did not have: a create command that installs at scaffold time failed outright, while a no-install one reported success and left an empty vendor that only showed up as a 500 once the site was linked. The framework declares its supported range and is resolved before the scaffold runs, so the scaffold now runs under the default clamped into that range, through the same start-or-prompt path every other containerized command uses.- A link onto a PHP version this machine had never built answered 502 (#1164). It registered the site cleanly, wrote the version's FPM quadlet and reported that version, but never built its image or left a container running behind it, and because
lerd php:listreads quadlets the freshly written one even made the version look installed. It is easy to reach without doing anything unusual, since an older framework clamps to the top of an older supported range and on a machine carrying only current versions there is nothing behind it. The link funnel checks whether the selected shared-FPM version has an image and builds it the way the install path does before the unit is started, streaming as its own link step. Where builds are withheld or a build fails the site is still registered and lerd names the command that finishes it rather than leaving a silent 502. lerd framework addrefused to install a published definition (#1163). Afterlerd framework searchshows a definition in the store the obvious next command refused with an unclear "--public-dir is required", because add only ever authored a user-defined framework by hand, where the public directory has to be given since there is nothing to read it from. Installing a published definition was left tolerd framework update, which is not what its name suggests, and the MCP framework add had the same shape, quietly writing a hollow stub that shadowed the store definition. A bare name with none of the authoring flags installs from the store now, resolving the version from the project in the current directory when there is one and otherwise taking the latest, withname@versionpinning one. Passing the authoring flags still authors by hand.- A never-migrated database reported unknown rather than a finding (#1149). A freshly linked project whose migrations have never run showed the migrations check as unknown with "Couldn't run the check, the app may be down or a dependency unreachable". Nothing is down: the status command simply exits non-zero saying the migration table is not there, and the catch-all collapsed every non-zero exit into that one sentence, which sent people looking at their database instead of at the migrate button the check already declares. A doctor command check can name a substring now that marks the finding triggered with its detail and fix ahead of the catch-all, so unknown keeps meaning a genuine connectivity problem and every framework gets the same escape hatch.
- A client shim rejected a bare
--versionprobe (#1128). Pointing an IDE at one was refused with "path to executable is wrong", because JetBrains validates a client executable by running it with--versionand reading the output back, and with no host on the command line the shim prepends its own so a bare dump lands on the local database. The postgres tools answer--versionand--helponly as their very first argument, so what actually ran exited non-zero with nothing on stdout. A leading version or help flag is recognised as a probe and forwarded exactly as given, read from the arguments rather than the tool name so it holds for every shim a service exposes, and a probe no longer starts the service behind the tool when its image is already present, since printing a version string never needed a database to be up and an IDE runs that check whenever the dialog opens. - The Logs tab was hidden when a worker was a site's only log source (#1110). The site detail gated the tab on app logs, a PHP-FPM or container runtime, or a supervised dev server, so a site whose sole source is a background worker, such as a proxy-only host-proxy site running a Stripe listener, matched none of them even though the inner view would have built the sub-tab and the stream worked. Whether to offer the tab runs through one helper that mirrors the sources the logs view actually builds, running and failing workers included.
- A proxy-only site showed as stopped while its dev server was up (#1108). The status dot is driven by the FPM running flag, which for a host-proxy site was computed only from its supervised dev-server unit, and proxy-only sites, where you start the server yourself and lerd only wires the proxy, have no such unit, so the dot stayed grey. For those sites the running state follows the proxied port now, a short probe reusing the same seam the worker health checks already use, while supervised host-proxy sites keep reflecting their worker unit exactly as before.
- The service Databases and Contents tabs asked for a running engine to be started (#1186). The Databases tab read whether an engine was running off the engine it had fetched, so for the whole round trip of the databases request a running engine looked stopped and the tab offered to start it, and the Contents tab had the mirror problem and rendered an empty panel until its kinds arrived. Both take the running state from the service the dashboard already polls now and show a loading line while the request is out, a request that never lands says so with a retry instead of an empty grid, and a refresh after a create or a drop keeps the cards already on screen.
- The worktree Based on picker did not stay on the branch you chose (#1226). The picker builds its first entry with an empty value, because an empty base ref means start from HEAD, and the dropdown decided what to render from the value alone, so that entry could never show its label: the field read Select... from the moment the modal opened and kept reading it after the current branch was picked, which made a perfectly good selection look like it had been ignored. The dropdown looks for a matching option first now and only falls back to the placeholder when nothing matches, which also brings back the All sites and All contexts labels on the dumps and queries filters. Editing the branch name refetches the form options, and that path treated a missing branch name as a first load, so it re-seeded the base ref, the build choice and the existing branch, and clearing the name field to retype it therefore threw away the base branch already chosen. Seeding belongs to the initial load alone now and a refetch only refreshes the option lists, with a sequence guard so a slow response for an older name cannot land on top of a newer one.
[1.30.1] - 2026-07-23
Two fixes: lerd start stopped prompting to reinstall the DNS sudoers rule on macOS every run, and the web UI's open-terminal button opens in the site directory again where ptyxis is the terminal.
Fixed
- macOS prompted to reinstall the DNS sudoers rule on every
lerd start(#1101). The passwordless-liveness probe that decides whether the drop-in is still active was hardcoded toresolvectl, a systemd tool that exists only on Linux, so on macOSsudo -nalways refused with "a password is required", which the probe read as a conclusive "the grant is gone" and rewrote the rule, prompting for sudo, even when the installed/etc/sudoers.d/lerdwas byte identical to its recorded hash. The probe command is platform specific now: Linux keepsresolvectl --version, macOS exercisesmkdir -p /etc/resolver, a command the drop-in already grants verbatim and which is idempotent, so it succeeds silently when the grant is live and only reports it gone when sudo genuinely refuses. - The open-terminal button opened in the home directory on ptyxis (#1103). The web UI launched ptyxis with a bare
--working-directory, but ptyxis is single-instance and applies that flag only alongside--new-window,--tabor-x, so it was ignored and a new window opened in the home directory instead of the site. The button passes--new-windownow, so the terminal opens in the site or worktree path. Every other emulator was already correct and is unchanged.
[1.30.0] - 2026-07-22
The 1.30.0 line shrinks what a PHP version owns and widens what the dashboard can reach. Custom extensions and packages became one declared set applied to every image lerd builds, and php.ini follows with a shared scope that fills the gaps under each version's own file, so a site that changes version stops silently losing what the developer asked for. The dashboard manages an engine's databases from its service page with snapshots, export and import, offers to install a service the project asks for but never had, keeps every notification in a bell that survives a reload, renders log output in colour and hands any log to a terminal, and groups the services list by type. lerd doctor stops merely reporting and learns to repair what it safely can, notifications grow a native desktop sink alongside the new Lerd desktop app, and the terminal dashboard moves onto the Charm v2 stack. Git worktrees get a round of their own, resolving their own PHP version, their own image, their own pin and their own database from wherever a command is run. Underneath all of it there is a long tail of resolution and resource work: .test keeps answering with no network at all and comes back after a sleep, nginx stops rejecting a config over a directive lerd also declares, and the daemon stops paying for background work while nobody is looking at it.
Added
- Custom PHP extensions and packages are one set across every version (#943). Extensions and packages were stored per PHP version, so a site that switched version silently lost the ones it had, an image built under 8.4 dropping chromium the moment the site moved to 8.3. They form one declared set now, applied to every image lerd builds, because they belong to the user rather than to a version. Because that set cannot always be honoured, lerd also records a realised set read back off each built image, so mongodb below 8.1 or an extension the legacy Alpine tiers cannot carry is reported as absent rather than falsely present. Rebuilds are lazy, fingerprinted by the declared set so a version rebuilds the next time it is used, changing a site's version funnels through one shared path so the CLI, the dashboard and MCP stay in step,
php:ext listandphp:pkg listreport the declared set with per-version reality, and the dashboard gains a PHP Extensions tab. - A shared php.ini scope across every PHP version (#944). A shared
95-lerd-shared.inimounts into every PHP container below the per-version98-lerd-user.ini. conf.d loads alphabetically, so a per-version key still wins and the shared file fills the gaps, which means a site changing PHP version no longer silently loses a setting. The migration is additive, existing per-version files keep working and keep winning, nothing is merged, and the mount back-fills onto existing installs on the next quadlet write. An unknown or removed directive on a given version is a startup notice rather than a fatal, so a version-specific key never breaks the others. It is reachable aslerd php:ini shared, an Editing scope dropdown on each version's php.ini tab, andini_read/ini_write/ini_resetwithsharedover MCP. The PHP page itself moves from a tab strip to a row of compact version cards, each showing the version with its full patch build, its status, and the default marker. - An engine's databases are managed from its service page (#966). Every database-level operation was reachable only from the CLI and the MCP db tool, so there was no way to see or act on your databases in the browser. Each database engine's detail page now opens on a Databases tab listing what is inside the running engine as cards with their sizes, and from a card you can create and drop a database, export it to a plain SQL dump or import one, copy a per-database connection string, and open it in an installed admin tool. Snapshots live on the card of the database they belong to, where you can take, restore, delete or download one, with named snapshots stamped with a UTC timestamp so they never collide. Cards link back to the site that owns a database, resolving a shared group database to the group main. The one new capability underneath is introspection, declared as an
introspect.list_databasescommand in the engine's service preset and run generically, so nothing in Go branches on the engine name. lerd doctor --fixrepairs the environment (#958). Doctor told you what was wrong and left the fixing to you. Every finding now carries a fix in one of three tiers: auto is lerd's to run and needs no privilege, creating a missing directory, enabling linger, installing the network-online drop-in, rebuilding a missing PHP image, repairing the DNS wiring, and after confirmation the heavier reinstalls and disk reclaims; manual needs sudo, so lerd prints the exact command and never runs it; the rest is external state it leaves alone with its hint. Fixes confirm one at a time, the heavy ones confirm even under--yes, and--dry-runshows what would happen without touching anything. The MCP diag tool gains adoctor_fixaction and reads the same structured report through a newdoctor --json, so the checks and their fix tiers live in one place.- Native desktop notifications as an opt-in delivery sink (#968). Notifications only reached browsers, over the WebSocket to open dashboards and via Web Push to subscribed ones, so a user with no dashboard open and no push subscription got nothing even though the daemon runs the whole time. The
lerd-uidaemon can now post straight toorg.freedesktop.Notificationson the session bus, with no new dependency. Native is a mutually exclusive sink chosen by a newnotifications.targetsetting so it never doubles up with browser delivery, an unset target resolves to browser so every upgrade and non-Linux install is unchanged, and a fresh Linux install asks which to use. Clicking a native notification opens the Lerd desktop app through itslerd://scheme when installed, or an installed PWA, falling back to the dashboard otherwise. - The Lerd desktop app (#968). The desktop app lives at lerd-env/lerd-desktop and ships as a Flatpak, served from lerd.sh so
flatpak install --user https://lerd.sh/lerd.flatpakrefresolves andflatpak updatekeeps it current. - Notifications land in the dashboard, with a bell that keeps them (#1036, #1035). Notifications only ever existed on the desktop, so anything raised while the user was working inside lerd was either a popup for something already on screen or, once dismissed, gone entirely. Every notification now lands in the page as well: a toast appears in the corner, informational ones clearing themselves and a failure staying until dismissed with a link to what failed, shown even when notifications are muted since an operation the user started failing is not something to swallow. The bell in the sidebar holds the last fifty with an unread count, persists across reloads, and each entry links back to the thing it was about. Delivery also follows the window now. Each socket connection reports whether its window has focus, so while a dashboard is being looked at the desktop post and the Web Push are skipped and the event arrives in the page instead, and a restarted
lerd-uireloads any open dashboard rather than leaving it running an older build's assets against a newer server. - Log output renders in colour, and any log can be followed in a terminal (#1065). Log panes were monochrome, and the renderer was never the reason: workers and UI commands write to a pipe, a launchd file or the journal, so artisan, composer, vite and pest saw no terminal and stripped their own colour before lerd ever read the line. One definition of the force-colour environment now travels with every path that starts a worker or a command, on both platforms, and
NO_COLORturns all of it back off. The browser side gained the other half: the SGR parser covers backgrounds, dim, italic, underline, the 256 colour cube and 24 bit colour, and it collapses carriage-return progress frames the way a terminal does instead of concatenating every frame composer and npm emit. A terminal button in each pane header hands the stream to the daemon, which resolves it back to its unit and tails it withpodman logs,tailorjournalctl, so a long tail survives closing the tab. - A host-proxy dev server restarts from the dashboard (#1058). A host-proxy site's dev server is its whole runtime and bouncing it is the routine fix when it wedges, but the overflow menu's restart is for container sites, so the only route was a terminal. The site header now carries a reload button for host-proxy sites that run a command. Restarting also stopped trusting the unit restart: dev servers that drain queues on shutdown hold their port for a moment after the process is told to go, and re-execing in that window got the app killed by address already in use, so the restart waits for the unit to go inactive and the port to come free, says so when it is still held, and starts the server anyway rather than leaving the site with no runtime at all.
- A missing service can be installed from the site overview (#946). A service listed in a project's
.lerd.yamlthat was never installed found nothing to join against and fell back to "Stopped", so it looked identical to one that is simply not running, and clicking it went to a Services tab entry that was not there. The card now shows an amber "Not installed", and clicking it offers to install the bundled preset inline, with a version dropdown for multi-version presets, jumping to the new service once it comes up. When no preset matches the name it says so and points at~/.config/lerd/servicesinstead. Reinstalling a removed built-in like redis works again too, where it used to fail with a name collision against the built-in it was trying to restore. - The services list groups by service type (#964). The Services panel rendered every core service as one flat list, which grew long once a project pulled in databases, caches, search and mail side by side. It now buckets them into labelled sections by category, in the same order the discovery grid already uses, with the per-site worker groups still listed below. The grouping is driven entirely by each service's category as resolved from its preset, so no service names are hardcoded.
- Semantic highlighting in the tinker editor (#973). phpantom_lsp moves to 0.9.0, which brings tighter type checking, far better Laravel resolution for Eloquent builders, relations, macros and facades, new refactorings in the lightbulb, smarter completion ranking and mago.toml aware formatting, all through the existing wiring. The new client capability is semantic highlighting: the editor colours each token from what phpantom resolved it to rather than from the static PHP grammar, so a resolved method reads differently from an unresolved one and framework magic is coloured like the symbol behind it. It falls back to the plain grammar when the language server is unavailable.
- Debug events captured during a test run are tagged and hidden by default (#1048). A feature suite fires hundreds of simulated requests, so a single run could push every genuine dump out of the buffer, and the only lever was disabling the bridge entirely before the run rather than after noticing the noise. Events captured inside a PHPUnit or Pest run now carry
ctx.test, keyed on PHPUnit's own bootstrap constant so it holds for any framework and any runner built on it, set at all three capture seams. The lenses read a stream with those dropped and a Show test runs checkbox brings them back, reporting how many are held back while they are hidden, so a dump added inside a failing test that never appears has a visible explanation. Tagging rather than dropping matters here: dumping inside a failing test is arguably the momentdd()earns its keep. - The MCP db tool lists the databases an engine holds (#1007). The dashboard gained database introspection, but the db tool never picked the capability up, leaving an assistant able to create, export and snapshot a database while unable to ask what an engine actually holds. A list action closes that, resolving the engine the same way the snapshot actions do, so naming a service picks it explicitly and omitting it falls back to the project's own database. An engine whose preset declares no introspect command says so rather than failing.
- The theme switcher is one icon with a mode menu (#1038). It was three stacked buttons labelled L, A and D, the letter-badge pattern the rest of the dashboard has moved away from, taking the vertical space of three icons to say one thing. It is now a single button showing the mode in effect, a sun, a moon or a half circle for following the system, opening a menu to pick between the three. On auto the tooltip names what the system resolved to, since the icon alone cannot say whether the page is currently light or dark.
- A debug group header links back to the site the request came from (#1047). Spotting something in the system Debug window meant going back to the Sites list and finding that site by hand. The bracketed name is now a link to that site's own Debug tab. The label was built as one string by three near-identical copies of the same function, so the builder now lives once and returns the site, the branch and the trailing text as parts that one shared component renders for the dumps, queries and kind lenses.
- Loopback-only pprof endpoints for profiling a running
lerd-ui(#1076). There was no way to see where the daemon spends its time on a user's machine, so a CPU report could only be answered by counting forks and reading code.lerd-uinow serves the standard Go profiling endpoints behind two gates: a marker file under the run directory, read per request rather than at startup because the process worth profiling is usually one misbehaving right now, and loopback, since these endpoints hand out goroutine stacks, the command line and heap contents. Both answer 404 rather than 403, so a daemon with profiling off looks like one that has no profiling surface. The release binary is stripped, but passing it togo tool pprofalongside the capture resolves lerd's own symbols. - A host path can be bind-mounted into the containers (#954). A new
mountskey inconfig.yamlmounts a specific host path into the PHP and nginx containers on top of the home and parked directories, overriding the denylist that keeps temporary system trees out by default, so an agent workflow can opt a scratch root under/tmpinto the containers without lerd mounting every random temp path.
Changed
- The daemon stops paying for background work nobody is looking at (#1073, #1074, #1079, #1075). Four background costs ran at full rate regardless of whether anyone had a dashboard open. The worker health detector shells out once per unit, so a 25 worker install paid 25 forks every five seconds for the life of the daemon; it now drops to sixty seconds while no window is visible and returns to five when one is, which keeps the failure notification the closed-dashboard user relies on while cutting idle CPU by roughly 83%. The dashboard polled
/api/statsevery five seconds against a three second cache, so every single poll missed and paid for a two secondpodman statsstream, and the TUI passed its own shorter TTL into the same shared cache and dragged the dashboard back down with it; the freshness window is now a property of the data, named once and honoured by both surfaces, and the share of wall clock spent streaming stats falls from 31% to 17%. The container cache went on pollingpodman psforever afterlerd stop, for containers deliberately down, and now settles on the post-teardown state and goes quiet until the marker clears. On macOS, enumerating unit states asked podman about each unit one at a time, which is memory-cheap insidelerd-uiand a VM round trip per unit everywhere else, so a worker health call over MCP took about 930ms on a 25 worker install; the sweep takes one snapshot up front and the same call now takes about 155ms with identical results. - A reload worker's file watcher polls once a second, and follows the power source (#1078). A worker opted into auto-reload runs chokidar in polling mode wherever the container cannot see host filesystem events, which is always the case on macOS and on WSL2 under
/mnt, and chokidar's default interval re-stats every watched file ten times a second. On macOS each of those stats crosses virtiofs and is served by the host VM process, which held near 30% of a core for a single site and has been reported at 161%. The watcher now carriesCHOKIDAR_INTERVAL, which chokidar honours however polling was switched on, so the vendored Octane and Horizon scripts are tuned without being patched. The interval tracks how the machine is powered, doubling on battery and again in low power mode, and a laptop unplugged mid-session has its polling workers restarted onto the new cadence rather than staying on the mains value until something else happens to restart them, held to one round every five minutes so plugging in and out does not bounce every worker. Power state is read over D-Bus from UPower and power-profiles-daemon first, since the sysfs file it used to key on is simply absent on much hardware, falling back to sysfs on a host with no bus at all. - The Debug view renders a page at a time and coalesces its stream (#1039, #1052). Every lens painted its entire filtered array in one go, and the client store holds up to 10000 events with a fresh connection replaying 3000, so a worker that had been running all afternoon put thousands of rows on screen. All three lens components now render the newest 100 rows and load the next 100 as the end of the list comes into view, which covers the system Debug window and every site's Debug tab across all eight tabs at once, with group headers still reporting a request's real size while its rows are partly windowed. Separately, every event arriving on the stream rewrote the shared store and changed its identity, so each of one PHP request's seven to eighteen events rebuilt every mounted lens from scratch. Events now land once per animation frame, and each event's search haystack and SQL fingerprint are computed once and cached weakly by event identity. Driving three hundred requests through a Laravel site with the Queries lens open, the tab went from thirteen seconds of blocking time and five frames a second to none and a steady sixty.
- The terminal dashboard moves to the Charm v2 stack (#960). bubbletea, lipgloss, huh, glamour and bubblezone move onto their v2 line, alongside a refresh of the wider Go module graph. bubbletea's new renderer redraws differentially with far lower bandwidth, adds synchronized updates that stop the flicker, and handles wide characters and emoji correctly. lipgloss dropped the adaptive colour that resolved light against dark at render time, so the two-tone greys and the on-accent foreground now start from the dark palette and flip once bubbletea reports the terminal background. A dark terminal looks exactly as before.
Fixed
.teststopped resolving once the machine went offline (#936). On systemd-resolved hosts the resolver refuses to answer anything, over glibc NSS as well asresolvectl, once no link is routable, so a fresh.testlookup failed with no network at all even though lerd-dns kept answering on loopback. The tell was a site that stayed reachable while the browser held a cached answer but failed the moment it was reopened. lerd now owns an always-up dummy link carrying the routing domain, so resolved keeps forwarding.testto lerd-dns offline, and it removes the global drop-in it supersedes rather than leaving a catch-all that hung every ordinary offline lookup on an upstream that was not there. The link is best effort: a host that cannot carry it loses offline resolution and nothing else. FallbackDNS is turned off alongside it on the distributions that ship it populated, the configureddns.tldis validated before it reaches a root-owned unit, and every privileged step is covered by a grant so the headless watcher can apply it without a prompt.- A host waking from sleep could lose lerd-dns and leave
.testdark (#1088). Nothing brought the container back, so every site on the local TLD stopped resolving untillerd startwas run by hand, while the watcher logged "DNS resolution broken" every thirty seconds without being able to act on it. Its repair only ever rewrote the host resolver configuration, which cannot help when the daemon that configuration points at is gone. It now asks lerd's dnsmasq directly on its own port whether it is alive and restarts the unit when it is not, ahead of the privilege gate since that heal needs no sudo, and a dnsmasq that answers is left alone so the VPN case behaves exactly as before. The other half was systemd: the NetworkManager dispatcher restarts lerd-dns on every interface change, and a wake bringing wifi, ethernet and a VPN back within seconds exhausted the default start rate limit and parked the unit in failed permanently. The limit is lifted for this unit and the watcher clears leftover failed state before restarting, so an install already stuck recovers on its own. - Uninstalling left the whole root-owned DNS setup behind (#1022). The Linux uninstall stopped the user units and removed the binary but said nothing about the rest: the link unit stayed enabled, so every boot recreated
lerd0pointing.testat a dnsmasq that no longer existed, the resolved drop-in kept FallbackDNS empty for good, and the passwordless sudoers grant for a tool being removed stayed in place. Once the binary was gone nothing on the machine could undo any of it. Uninstall now offers to runlerd dns:disablewhile the binary is still there and prints the root commands to clear it by hand when that is declined or the binary has already gone. The fallback drop-in was also written under a guard that only covered the first run, so a host that hadlerd0and later lost it kept the drop-in from the run that worked and was left with resolved's fallbacks off and no link, which is exactly the state the guard exists to prevent; the failure path hands the fallback servers back now, on the grants-out-of-date path too. - The passwordless DNS grant outlived the tool, and a deleted one was never restored (#1098).
/etc/sudoers.d/lerdis a standing NOPASSWD root grant coveringresolvectl, restarting NetworkManager, and writing a root-run dispatcher script. Uninstall now removes it, so it lasts exactly as long as lerd does. Alongside that, lerd could no longer tell whether the grant was still there. It cannot read the drop-in back, since/etc/sudoers.dis root-only, so it keeps a content marker and asks sudo whether the grant is live, and only a conclusive refusal forces a rewrite. That question was being asked three broken ways at once. It ransudo -lagainst the granted command, which reports whether the command is permitted rather than whether it runs without a password, so anyone carrying a broader rule got a success and, on the sudo Ubuntu 26.04 ships, so did everyone else. It matched only classic sudo's wording, not that same sudo's. And it read the refusal in whatever language the host speaks, so a Romanian machine never matched at all. Each one alone was enough to leave a deleted drop-in unrepaired while lerd advised running the command that had just failed to repair it. The probe runs the granted command now, in a pinned locale, and the drop-in permits its own removal so the teardown needs no password. - A site whose bind source had gone took the whole stack down (#1084). Podman refuses to start a container with a missing bind source, so a single project subdirectory removed by a branch checkout aborted the start with a statfs error, and because nginx serves every site that left no site reachable until the branch was checked out again. A start-time preflight now sweeps the quadlets, removes the self-mounts whose host path no longer exists and names the path and the site it belongs to, so the responsible project is obvious instead of buried in a journal. Only absolute-path self-mounts are considered, so the config, socket and ini mounts lerd creates on demand are left alone, and the path collection behind the quadlet rewrites drops missing candidates too, since otherwise a link elsewhere in the same session would re-add the dead path.
- A directive lerd sets at http level could not be overridden (#1072, #1082). Setting
client_max_body_sizein the global http override made nginx refuse the entire config with "directive is duplicate": the override file is included insidehttp {}where lerd already sets it, and nginx fails on a repeated simple directive rather than letting the later one win, so every http-level default lerd ships was impossible to override no matter how the file was written. The generatednginx.confnow yields the field, reading the directive names declared at the top level ofhttp.dand commenting its own default out for each one with a visible marker so the shipped value stays discoverable. It is name-driven rather than a list of special cases, and removing the override brings the defaults back.log_formatandaccess_logare exempt, because they are repeatable and nginx is happy to carry both: commenting lerd'slog_formatout left itsaccess_lognaming a format nginx no longer knew, which failed the config check and took every site down, and that same feed is what idle-suspend and per-site request timing read. - A project outside the home directory was unreachable from the containers (#1054). Linking one wrote the bind mount into the PHP-FPM and nginx quadlets but left the containers running with the mounts they booted with, so the site path did not exist inside them and the first exec died with a crun chdir error. The rewrite that would have restarted them diffs its render against the file just written, finds no change, and restarts nothing, and the path guard skipped it for the same reason: the Volume line was right there in the file. Both now ask the running container what it is actually mounted before deciding, so a correct quadlet is no longer taken as proof. A stopped container is never treated as drifted since it picks the quadlet up when it next starts, a symlinked path is compared in resolved form so it cannot trigger a restart on every call, and per-site custom-FPM quadlets are covered, which were skipped entirely and left those sites unreachable outside home.
- Worktrees resolved their parent site's PHP version, image and pin (#999, #1001, #1021, #1024). A git worktree checked out inside its parent's directory matches that site by path prefix, and four separate lookups stopped there. The CLI read the parent's registry version while the worktree's own vhost was generated from its pin, so the browser served the branch on one PHP version while
lerd phpandlerd composerin the same directory ran another. The container lookup missed for a sibling checkout and fell back to the shared per-version container, so a site running its own image from a project Containerfile had nothing that image adds present in the branch. Declining an install and picking another version from inside a worktree wrote.php-versionat the parent root and left the branch with no pin at all. And a database client shim read the parent'sDB_HOST, so a branch with an isolated database could be dumped from the parent's data and a branch on a different engine targeted the wrong container outright. All four now resolve the checkout the same way, and the rules behind them live in one place that the CLI and the MCP server both delegate to, so the version a command runs on and the version its container serves cannot drift apart again. Inherit-by-default is unchanged: a worktree with no pin still follows its parent. Switching a worktree's version also runs the same runtime setup a site switch does, where before it wrote the pins and the vhost and left a branch moved to a version with no quadlet on the machine pointing at a container that was never created. - The database endpoints trusted the names they were given, and answered the LAN (#1008, #1082). Both arrived with the Databases tab during the 1.30 betas and were fixed before this release, so no stable version carried either. The endpoints took the database and name values straight from the request while two sinks downstream assumed they had already been slugged: the snapshot handlers joined the database name into the snapshot path, so a traversing value resolved outside the snapshot root and reached
os.RemoveAllon a real directory, with directory creation and arbitrary dump reads available the same way. Database names are checked against a strict pattern now, both at the HTTP boundary and inside the snapshot entry points so the MCP and CLI callers are covered by the same guard, and snapshot export runs its own name through it too. The existence lookup for MySQL and MariaDB also interpolated the name through an escape that doubled the quote but left the backslash alone, and MySQL treats a backslash as an escape unlessNO_BACKSLASH_ESCAPESis set, so a backslash escaped the doubled quote and the rest of the value ran as a second statement; those two sinks double the backslash first now, while PostgreSQL keeps the old escape sincestandard_conforming_stringsmakes a backslash ordinary there. Separately the whole subtree was reachable from the LAN with remote-control credentials, so an authenticated client on the same network could export a database in full or drop it. A site's raw.envis already held to loopback for carrying the credentials to that data, and this surface both reads the data out and overwrites it, so it now sits behind the same gate and the tab is not offered on a LAN-exposed dashboard. - Values spliced into a translated message reached the browser unescaped (#1082). Translated messages are rendered through
{@html}, and paraglide compiles a message to a plain template literal that escapes nothing, so the remote-control username, the LAN address, the setup code and the configured TLD all travelled that path verbatim. They are escaped where they are spliced in. - The shared ssh-agent crash-looped after any stop (#1067). ssh-agent refuses to bind a socket path that already exists and only unlinks it on SIGINT, SIGTERM or SIGHUP, never on the PHP image's SIGQUIT stop signal, so the socket survived every stop on its persistent volume and every subsequent start failed instantly and was respawned by
Restart=always. Sincelerd auth sshrecreates the container, the command that needs the agent was the one re-orphaning the socket, racing a window of tens of milliseconds and failing with "cannot exec in a container that is not running". The agent command is wrapped so the stale socket is removed before exec, making the container restart-safe however it was stopped. On macOS the argv read back out of a plist was not being unescaped even though the write path escaped properly, so any argument carrying an ampersand or an angle bracket reached podman still escaped, which broke that wrapper and the FrankenPHP worker exec alike. lerd php:ext addfailed for every extension on the default path (#1044). The fast path layers the custom extension block straight onto the pre-built base, which is the runtime image and carries no compilers, sopecl installgot as far asphpizeand stopped, while the same block on the local path sits in the builder stage where the toolchain already exists, which is why--localworked and the default did not. The fast path now installs the compilers as a virtual package, builds, and purges them inside the same layer, so the two paths agree and the runtime image stays the size it was. Nothing surfaced this, because each extension step ends in|| trueso that one entry a version genuinely cannot have does not fail the image for the versions that can, which absorbed a total failure with machinery meant for a partial one; an entry that landed on no version at all is reported as the build failing now rather than as a capability boundary.lerd pest:browser installhung forever with no output (#1009). Playwright's Node-based zip extractor deadlocks partway through writing into the bind-mounted browser cache inside the FPM container, so every archive downloaded to 100 percent and then sat there with no error and no timeout, deterministically and regardless of size. The install now asks the project's Playwright where each component belongs and what to fetch with a dry run, downloads the archives with curl and unpacks them with the image's own unzip, keeping Playwright's mirror fallback for the components Microsoft mirrors and writing the registry link entry Playwright expects so a second site's install cannot reap the browsers this one just downloaded. Aborting used to make things worse, since Ctrl+C killed the host side only and left the in-container installer holding Playwright's install lock, so every retry blocked on it silently; the lock and any orphaned downloader are cleared before the install, after a failure and on interrupt, and the half-downloaded archives nothing ever cleaned up are reaped.- A Postgres import that half failed reported success (#1056).
psqlexits 0 whether a dump loaded cleanly or every statement in it failed, so an import that half landed looked identical to one that worked and its output went in the bin. Imports and restores now count what the engine wrote and keep its errors in the order it hit them, since the first failure is what causes the rest, folding the flood of unparsed COPY data that follows a missing table into one line so it cannot crowd the cause out.db:import,db:restoreand a cross-version service migrate end on a warning instead of claiming the import completed, and the MCP restore tool passes the same summary back to whoever called it. - A migrate against a stopped or MariaDB service failed before touching anything (#1032, #1025). A service no site uses is auto-stopped, and that is the normal state to reach for the Migrate button from, but the migrate went straight to dumping, which execs into the container, so it failed on "no such container" and the notification reported a failed migration for a service that simply was not running. It brings the unit up first now and waits on the family's readiness probe. Separately, the mysql-family migrate spelled its dump as a literal
mysqldumpand its probe and restore asmysqlwith a fallback behind||, and the MariaDB images carry only the mariadb-named binaries, so migrating a MariaDB service failed immediately on the dump. Those three in-container commands resolve their tool at runtime the way the snapshot and import paths already did. lerd cleanupleft several GB of unused service images unreclaimed (#981). Cleanup only treated a catalog service image as reclaimable when lerd's pull ledger recorded pulling it, but the ledger is written only in lerd's explicit pull wrapper, and a service quadlet starting for the first time has podman auto-pull the image without going through that path. Cleanup could report tens of MiB while several GB of old service versions and dropped services sat unreclaimed, visible only topodman system df. The deep tier now reclaims an unprotected, not-in-use image whose repo is in the service catalog even without a ledger entry, so a deep sweep recovers images pulled before this change, while the managed and safe tiers keep the ledger gate so the background sweep still never touches an image lerd cannot prove is its own. Service installs also record the image at the choke point every service quadlet passes through, so the ledger stays complete going forward.- Most worker failure notifications described a worker that had already recovered (#1018). Worker units carry
Restart=alwayswith a five second delay, so a worker that crashes once is usually active again within a cycle or two, and the watcher's five second tick plus a five second batch window put the notification right in the middle of the interval systemd uses to fix the thing itself. Arriving at a dashboard with a cleared banner and everything green teaches you to ignore the ones that matter. A new failure is held for thirty seconds now, still grouping anything else that trips in that window into one notification, and health is re-read before dispatch: workers that came back are dropped and the ones that stayed down carry the state read at flush time, so a unit that went from a crash loop to plain stopped is described as it actually is. A detector error keeps the whole batch, since failing to confirm is not evidence of recovery. The dashboard banner is a separate path and still appears immediately. The D-Bus calls behind all of this were also unbounded, so a wedged notification daemon blocked the calling goroutine forever, which reaches the mailpit webhook and the service install, migrate and reinstall handlers at one stuck goroutine per event, and the PHP install handler held a lock that left a version unable to be reinstalled without restartinglerd-ui. Every call gives up after three seconds, discovery no longer autostarts adbus-daemonthat outlives lerd over SSH, and the route map is capped rather than growing unbounded when the click listener cannot come up. - The DNS doctor failed a healthy
lan:exposehost (#1092). The rungs treated 127.0.0.1 as the only acceptable answer, so on a setup where dnsmasq legitimately answers the host LAN IP the doctor reported a failure while the TLD resolved and sites served correctly. The direct dnsmasq query and the end-to-end system lookup accept the LAN IP as well whenlan:exposeis on, derived the same way the probe already does it, and the two hints they print point atlerd dns:repairrather than asystemctlinvocation that does not exist on macOS. - Debug events from a terminal command arrived with no site attached (#1041). The devtools extension reads
LERD_SITEfrom the environment andlerd phpnever passed it, so every query, job and event raised by a terminal command reached the bridge unattributed while dumps quietly guessed a site from the directory name.lerd phpforwards the registered site name now, with a worktree checkout reporting its parent the way tinker and the worktree vhost already do. Every debug notification also resolves its route the same way rather than each kind deciding for itself, so clicking one lands on the site's own Debug tab where the event and its context are, instead of a global view that says nothing about what was just clicked. - Idle-suspend put proxy-only sites to sleep (#1086). A proxy-only site forwards its domain to a dev server the user starts themselves, with no command for lerd to supervise, so there is no process the idle engine could stop and suspending it achieves nothing while still presenting it as asleep across the CLI and the dashboard. It is never suspended now, the same way a pinned site is not, a stale suspended list is still resumed rather than stranded, and
lerd idle statusreads "proxy only" instead of an idle duration it can never act on. A host-proxy site whose command lerd does supervise keeps sleeping exactly as before. - Every slow route offered to profile itself, on sites SPX cannot profile (#1060). SPX ships in the FPM image, so on a FrankenPHP site, a custom container, a host-proxy dev server or a static site the click armed the global profiler, opened the route, and left you in front of a profiler that would never receive a capture. The site payload now carries whether SPX can profile it, decided by the same rule the vhost rewrite uses to pick which sites get the SPX cookie, so the two cannot drift. Where it cannot, the slow routes still rank and read as before.
- The site doctor opened onto nothing where no check could apply (#945). The doctor button showed on every site, but on a host-proxy running a Python, Ruby or Go dev server there is no framework, no
composer.jsonand nopackage.json, so every check self-skipped and the modal opened empty. It now keys on whether any check applies rather than on the site type, which matters because a Node host-proxy has apackage.jsonand still gets its dependency and audit checks. That predicate lives next to the doctor engine, so the dashboard, the CLI and MCP share one answer instead of each deciding for itself. - Unlinking a site left its request and idle state behind (#948). Unlink tore down the vhost, certs, workers, custom containers and registry entry but left the per-site state the watcher writes, so an unlinked site kept its entry in the request-timing and idle-activity snapshots and its recorded requests stayed in the durable store indefinitely. Nothing broke, since both snapshots are read by key lookup and a stale key is never resolved back into a site, but it was unbounded growth. The shared unlink path drops that state now, so the CLI, MCP and parked-watcher callers all get it, and a control-socket message tells the running watcher to forget its in-memory copy rather than write the files straight back on the next tick. Worktree keys are namespaced, so a site's worktree rows go too.
- Parts of the dashboard rendered English whatever the language (#1081). The lerd-dns disabled notice, the no-DNS branch of the remote control description, the duplicate query tooltip, the TLS state labels on the site header and the disabled pill on the DNS panel were written straight into the markup, and the stores were the bigger source of it: every failed API call fell back to a raw English string, so a non-English user hit English the moment anything went wrong. Those resolve through the catalog now, with a shared set of keys for the fallbacks rather than the same message spelled out in sixteen stores. The catalogs themselves had drifted too. Key parity held, so nothing looked wrong from outside, but a large number of values were still sitting on their English source text, and all thirteen non-English catalogs are filled in against the terminology and register each had already established.
lerd phpleaked a container error from an unreachable directory (#954). Running it from a directory the PHP container cannot reach, most often a scratch path under/tmp, passed the working directory straight through and let the runtime fail with an opaque chdir error. The shim checks the directory is reachable first and refuses with a message that points at the fix, the same guardlerd newalready applies, and a plain data-file argument is no longer rewritten to/dev/stdin, so only the first script operand is streamed and a real path argument reaches the script unchanged.- The auto theme ignored a live system light and dark switch (#971). It only picked the change up on a full reload. The
prefers-color-schemelistener re-set the theme store to its current value, which Svelte treats as a no-op and never notifies subscribers on, so the step that toggles the dark class never ran. The current theme is re-applied directly on the media change now, so auto follows the desktop live, in the browser and in the desktop app. - The dashboard cards left dead space instead of filling the window (#983). The cards were pinned to a 340px ceiling while the grid rows sized themselves to their content, so on a tall window the six cards stopped short and left a band of empty space under the second row. On the wide three column layout the grid now claims the height left below the header and splits it between its two rows, with the ceiling lifted so each card stretches into its share and the floor dropped so a short window shrinks the rows rather than clipping them. The narrower stacked layouts scroll as before and keep a floor instead, where filling the viewport would only squash the cards.
[1.29.0] - 2026-07-15
The 1.29.0 line pushes more of lerd's behaviour out of Go and into the stores: a framework definition can now declare the nginx config it needs, an env file that is a returned PHP array rather than a dotenv, the php.ini its CLI runs under, the services it cannot run without, and what its worktrees need to come up, while a service preset can declare its category, its icon, and the admin dashboard it fronts. On top of that, sites group into workspaces, worker self-heal learns to spot a worker that is running but no longer serving, Node projects are driven through whichever package manager they actually pin, and the dashboard and TUI both grow a round of request-timing and resource surfaces. The stable release also makes lerd a first-class citizen on atomic images: Fedora Silverblue, Bazzite and the other ostree spins boot and install cleanly, with the 90-second container-start stall gone and browser HTTPS trust explained rather than silently skipped.
Added
- A captured query copies with its bindings inlined (#925). The Queries lens shows the SQL and its bindings separately, so copying the SQL alone left the
?placeholders unresolved. A copy button beside each query's duration resolves the positional placeholders into escaped SQL literals, strings quoted with their single quotes doubled,NULLfor nulls,1and0for booleans and JSON for structured values, while question marks inside quoted strings are left alone, so the copied query runs in a SQL editor as-is. - An opt-in high-contrast tray icon (#917). The colour running icon adapts to the desktop's light or dark preference, but on mixed themes like KDE's Breeze Twilight, light application colours over a dark panel, that preference does not describe the panel and the icon disappears into it. A high-contrast style draws the running icon as a single green L that reads on any background the way the stopped icon already does. It is off by default, turned on with
lerd tray icon high-contrastor the new tray menu item. - Sites group into workspaces (#827). A workspace is a named bucket of sites that survives across the dashboard, the overview grouping and the TUI's
sort: workspace, so a machine carrying three clients' projects stops presenting them as one flat list. Distinct from site groups, which stay a domain-level concept. - A framework definition declares its nginx config and can keep its env in a returned PHP array (#845). A definition can carry an
nginx:snippet spliced into the site's vhost, so a framework that needs its own asset or rewrite routes stops depending on lerd knowing its name, andphp-arrayjoins dotenv as an env format so frameworks whose config is a returned PHP array (Magento'senv.php) are wired through the same env machinery as everyone else. - A framework definition declares the services it cannot run without (#847). A definition can list required services, and linking a project that needs them offers to install them rather than leaving the site to fail on first request.
- A framework definition declares the php.ini its CLI needs (#848). The CLI container picks up the definition's
php.cli_ini, so a framework that needs a different memory limit or a different set of directives than FPM gets it without a global override. - A framework definition declares what its worktrees need (#892). A definition can name the database isolation a worktree requires, what an isolated database is cloned from, and the console commands to run once the file and the database are in place. Magento asks for an isolated database cloned from main and for
app:config:import, and its worktrees now come up on their own domain with nothing to run by hand, where before the store 500'd on a config hash the shared database disagreed with. Nothing in Go knows what any of those commands mean; they run through the framework's own console binary. - A service preset declares its category, its icon, and the service it administers (#852, #822, #825). Presets carry
category,iconandadmin_for, so an admin dashboard is presented as the front end for the service it fronts rather than as another unrelated tile. Installed services pick up the preset card shell, and the site overview's service cards offer to open a suggested admin dashboard directly. - Worker self-heal catches a worker that runs but has stopped serving (#841). A worker whose unit is happily active but whose dev server has died no longer reads as healthy: the probe dials the URL the worker publishes and flags it unreachable, and per-worktree worker units are covered by the same pass, so a dead per-worktree Vite is healed like any other.
- Node projects run through the package manager they pin (#812). A project that pins pnpm or yarn, via
packageManageror its lockfile, is driven through corepack withpnpm install --frozen-lockfileoryarn install --immutableinstead of having npm forced on it. The setup wizard's step labels follow the manager it detected. - The dashboard shows reclaimable disk and cleans it up in place (#857). The resources widget carries a reclaimable-disk figure alongside CPU and memory, with a cleanup modal behind it, so the space
lerd cleanupwould reclaim is visible without running it first. - The sites list can be ordered by real request traffic (#859). Recently-used and most-used sort modes are backed by the request store, so the site being worked on surfaces without being pinned by hand.
- The site Env tab names the file it is editing (#865, #858). The toolbar shows the dotenv path the framework actually resolves to, which for a worktree is the worktree's own file, so it is no longer ambiguous which env a change is landing in.
- The TUI grows a request-timing pane, a dedicated logs tab, and a responsive site overview (#868, #872). Request timing joins the site overview, logs get a tab of their own rather than sharing a pane, and the overview lays out as a grid that reflows with the terminal.
- An AI assistant can reach workspaces, and knows what a service preset administers. MCP gains a twelfth grouped tool,
workspace(list, create, rename, delete, assign, move), so an assistant can organise a site list rather than only read it, andpreset_listnow carries each preset'scategory,iconandadmin_for, which is what makes "which dashboard administers this database" answerable: phpMyAdmin administers mariadb as well as mysql, and that is not the same relationship as depending on it. The MCP handshake reports lerd's real version instead of a hardcoded one, and the reference every assistant reads no longer describes lerd as a Laravel-only environment or claims that Node work runs through npm.
Fixed
- lerd runs cleanly on Fedora Silverblue and other atomic images (#928). Podman's quadlet generator makes every rootless container wait on
network-online.target, which nothing activates on atomic images, so it timed out after 90 seconds and dragged every container start and the whole boot out with it. lerd now writes a drop-in that turns that wait into a no-op on hosts where the target never comes up, gated so ordinary distributions are untouched, since lerd only ever binds loopback. The system tray was the other half:lerd-traylinks a library these images do not carry, so install now leaves the tray unit stopped and disabled when its libraries will not resolve rather than letting a failed unit leave the systemd user session degraded, andlerd doctorreports both conditions. - Missing certutil no longer leaves
.testHTTPS silently broken in the browser (#931). On atomic imagescertutilcannot be installed without layering a package, so mkcert trusts the CA in the system store but skips the browser's NSS databases and only warns, a warning lerd swallowed, leaving certificates that worked on the command line but threw errors in the browser. lerd now surfaces the missing certutil during install,dns:enableandlerd doctor, explaining that curl, PHP and openssl trust.testwhile the browser will not, and pointing at either layeringnss-toolsthen runningdns:repairor falling back to.localhostover http. The bootstrap installer learns the same, consultingID_LIKEso an atomic spin like Bazzite is recognised instead of dying on a package install that cannot succeed. - A project no longer lists twice on atomic images (#932). On atomic images
/homeis a symlink to/var/home, so the working directory can come back under either spelling, and linking a project through each registered it as two separate sites, the second disambiguated with a numeric suffix. Paths are resolved before they are stored and compared now, so the two spellings collapse to a single site, and an entry left over under the unresolved spelling still matches. - Profiling a slowest route on a localhost site opens over http (#935). The per-site timing overview built the profile URL with a hardcoded https scheme, so profiling a route on a
.localhostsite, which lerd serves over plain http with no certificate, failed with a certificate error. The URL follows the site's TLS state now, http for an unsecured localhost site, https for a secured.testsite. lerd checkstops flagging the Stripe and host-proxy workers (#927). check validated every worker in a site's list against the framework's definitions and failed for anything else, so the Stripe listener and the host-proxy dev server, which legitimately run outside a framework, reported a red error on a healthy site. check recognises the built-in workers now through the same shared helper the rest of lerd already uses, while a genuinely undefined worker still fails..lerd.yamlis written atomically (#924). The file was written with a bare, non-atomic write, so a process killed mid-write or two callers racing could leave it truncated, and since most readers treat invalid YAML like a missing file, a site would silently lose its pinned versions, domains, services and workers. The write goes through the package's atomic writer now, and the output is normalised to a stable, canonically-ordered form so a worker starting or stopping produces a minimal git diff instead of a reshuffled block.- Console commands run inside git worktrees (#921).
lerd artisanandlerd consolelooked the site up by an exact path match, and a worktree checkout is never registered as its own site, so they failed with "no framework assigned" even on a served worktree whose parent was linked. They inherit the parent site's framework for worktree paths now, the same way the worker commands andlerd tinkeralready do, and the MCP console tool is fixed with them. - The CLI accent follows the terminal theme (#919). The CLI and TUI shared a palette whose interactive accent was the brand red, the same family as the failure colour, so a running spinner, an accented value or a focused border all read as an error. The signal colours use ANSI terminal-theme codes now, so the accent and running status render as the terminal's green, failures as its red and warnings as its yellow, following whatever theme the user runs, while the web UI keeps its brand red.
- A Symfony project had its service credentials written into the git-tracked
.env(#883, #885). The env wiring and the db commands still resolved the Go built-in framework definition while the doctor and the UI had moved to the store-first, version-aware resolver, and for Symfony the two disagreed: Symfony commits.envand gitignores.env.localas the local override, so setup saw a committed.env, took the updating branch, and wroteDATABASE_URLand the rest of the credentials into the tracked file, never creating.env.local. Meanwhile the Env tab and the doctor looked for.env.local, did not find it, and reported the site as unconfigured. Both call sites resolve through the same definition as everything else now, and worktree env sync follows the same resolver. - A worktree 404'd on every framework that does not serve from
public(#878). A worktree vhost hardcoded its document root topublic, so a Magento (pub), Drupal (web), CakePHP (webroot) or WordPress worktree answered every request with a 404. The worktree generators run the parent site through the same resolvers the main vhosts already use, so the root follows the framework'spublic_dir. - Resolving a framework wrote to the project (#906). The resolver repinned
framework_versionin.lerd.yamlwhenever composer disagreed with the file, and it is reached from vhost rendering, dashboard polls, the TUI and the watcher, so a checkout found its committed.lerd.yamlmodified with no command having been run. It is a read-only resolver now, and the commands that own the file sync the version explicitly. - A scheduled worker was shown as stopped (#850). A worker with a schedule is a oneshot service driven by a sibling timer, so its service unit is inactive between ticks and worker status, reading the service, drew a perfectly healthy Laravel scheduler or Magento cron as stopped. Liveness for a scheduled worker is now the timer's state, while a failed last run still surfaces from the service.
- A worktree's worker was reported against the wrong site (#906). Worker units were resolved by the longest matching site suffix before their working directory was consulted, so a worktree checked out into a directory named after another registered site was attributed to that site, under a worker it does not have. The checkout settles it first.
- The PHP images advertised extensions they did not load (#889, #887).
BundledExtensionsis what lerd tells a project it already has, and nothing could contradict it, which is howext-ftpsurvived as an advertised extension nothing installed. The base-image build now compares the whole advertised set againstphp -min the image it just built, which turned up two more of the same bug:ext-randomis core only from 8.2, and PECL mongodb no longer builds below 8.1, so three of the seven published images did not load something the list promised. The advertised set resolves per PHP version, an extension a site's PHP cannot have is explained rather than offered for install, and composer'sext-zend-opcacheresolves against the bundled opcache instead of failing a satisfiable requirement. - The CLI ran on a different PHP version than the site's FPM served (#889).
lerd linkclamps a site's PHP to the framework's supported range, but composer, console,phpandphp:shelleach re-detected the version from the directory, so a project pinning.php-versionto 8.1 could be served by 8.5 while composer ran inside an 8.1 container it started for itself. They resolve the registered site's version now, and link writes the resolved version back into.php-versionrather than leaving the pin contradicting reality. - A commented-out line in
.envkept a service listed as permanently stopped. A site's services are enumerated from container hostname references in its.env, and the match scanned the file as one blob, so a disabled#DB_HOST=lerd-mysql, or a live line carrying a trailing# was lerd-mysqlcomment, still counted as a live reference and left a grey Stopped badge that never went away. Commented lines and trailing comments are skipped, while a#inside a value is preserved. - The services list showed presets that were removed or never installed. A default preset dropped from the store lingered in a site's service list, and default-preset services that are not installed were listed alongside the ones that are.
- A site left on the old TLD by a declined rewrite lost HTTPS on re-enable. Re-enabling such a site did not restore its certificate, so it came back on plain HTTP.
- Containers resolved a stale nginx address after a restart (#818). When nginx came back on a new IP the container hosts files still carried the old one, so requests between containers went nowhere until the next full restart.
- A group's wildcard certificate could serve a secondary's subdomain (#819). A secondary site in a group is secured on its own so the main site's wildcard cannot answer for it.
- Request timing counted WebSocket upgrades and reported the wrong p50 (#834, #835, #861). Long-lived WebSocket upgrades were recorded as ordinary requests and dominated the timing view, the live snapshot reported something other than the real p50, and a worktree's requests were neither recorded nor read back.
- A site whose path contains a space would not serve (#896).
lerd newleaked its own flags into the scaffold command (#897). The flags lerd parses for itself were passed through to the underlying scaffolder, and the target directory was not mounted into the container that runs it.- A rewritten nginx quadlet dropped mounts for projects outside
$HOME(#903). Regenerating the unit lost the out-of-home bind mounts, so a site living outside the home directory stopped serving until it was relinked. lerd site:doctorignored--silent, mis-reported severity, and did not confirm its fixes (#901). It also named only the first service that was down rather than every one of them.- Installs fell back to a slow from-source PHP build (#880). The base-image manifest job ran only when the entire build matrix succeeded, so a single arch or version failure skipped manifest creation for all seven PHP versions, and with the arch-less tag unpublished there was no prebuilt base for the current Containerfile hash. Manifests are published per version now, and a version missing an arch fails its own cell with a clear message instead of quietly leaving the tag unpublished.
- The rustfs readiness probe checked the wrong port (#882).
- Unlink and worktree database drop asked for confirmation in a native browser dialog (#871). Both now confirm in a lerd modal, consistent with every other destructive action in the dashboard.
- The watcher could exit with the idle engine's work still in flight (#862).
- A wrapped translation broke the star card's sentence (#910).
Security
- A project path could bind-mount the filesystem root into containers (#886). A crafted or malformed path resolved to
/, which was then mounted into the site's containers. The root is never mounted now, whatever the path resolves to.
[1.28.1] - 2026-07-08
A patch release that gets the v1.28.0 max_allowed_packet change to actually reach running services on update, and hardens the framework and service store against transient fetch failures.
Fixed
- A shipped preset config change reaches a running service on update, not only on reinstall (#805). The v1.28.0 bump of MySQL's
max_allowed_packetonly took effect after an explicitlerd service reinstall, because the reconcile pass regenerated a service's config files only when its unit was missing and never restarted a running container. Reconcile now re-materialises a running service's preset files and restarts the container when its config file is newer than the container's boot, meaning the config genuinely drifted, so a shipped preset config change lands on a plainlerd update. The mod time only advances on a real content change, so a steady state restarts nothing, and the same drift check covers both the default stack and store services. - A transient store fetch is retried instead of dropping a definition (#807). Refreshing the framework or service store fetches each definition over its own request, and a single slow
raw.githubusercontent.comresponse would fail that entry with a context-deadline error and leave it stale until the next refresh. A fetch now retries a transient failure, a request timeout, a dropped connection, or a 5xx, up to three attempts with a short backoff, while still giving up immediately on a definitive 4xx such as a removed definition.
Changed
- Go dependencies refreshed (#806).
golang.org/x/netmoves to v0.56.0 and the module graph is re-tidied, clearing stale Dependabot alerts that lingered against already-patched versions.
[1.28.0] - 2026-07-08
This release turns the per-request signal lerd already carried into a full request-timing analytics view for every site, moves service presets to the same external store frameworks already use, and rounds out the AI diagnosis surface so an assistant can find a slow route, its N+1 queries, and its CPU hotspots in one call. Alongside that, TLS certificates renew themselves before they expire, DNS switches on and off through dedicated commands, database client tools become host shims, lerd cleanup reclaims real disk by default, the site page is rebuilt as a resource view, and the dashboard grows a QR code, a mobile session, and a round of phone polish.
Added
- Per-site request timing analytics (#723, #796). The nginx access feed, previously starved down to the request host for idle-suspend, now carries status, request time, method and URI, and a framework-agnostic aggregator keeps a rolling per-site, per-route window off that same feed. The site Overview gains a Request timing section driven by a durable SQLite record: a range picker, KPIs for the typical and p95 response, request count and error rate, a response-time distribution, a throughput chart, the slowest routes with one-click Profile, and routes and recent-requests panels. Routes are grouped after collapsing id-like path segments and dropping query strings, and a route is flagged when its p95 runs well above the site's own typical response and it has cleared a minimum sample count. The first request after a site has sat idle past the suspend timeout is recorded with a cold flag and kept out of every timing figure while still counting toward the total. The same finding reaches you without the panel open, as a Response Time warning in the site doctor that the CLI, the dashboard, and MCP all inherit, and as an opt-in
slow_routepush notification that fires once when a route crosses the threshold and rearms when it drops back. - AI can diagnose a slow site from real traffic (#725, #727). MCP
diag route_timingreturns the per-site response-time table read from the timing snapshot, andoptimize_routejoins each slow route to the N+1 and slow queries captured behind it, with the caller file:line, plus aprofileblock of the top functions by exclusive wall time distilled from the SPX capture of that route, so a CPU-bound route shows where its time went next to its queries. A newspxreportpackage parses SPX's on-disk trace into a flat profile and keeps only the outliers, the same distilled hotspotslerd profile run --flatandprofiler_reportreturn for a command. - Service presets move to an external store (#704, #713). Service presets adopt the fetch-and-cache store model frameworks already use. The binary embeds only the default stack (mysql, redis, postgres, meilisearch, rustfs, mailpit); every add-on lives in the
lerd-env/servicesstore, fetched on demand into a local cache, refreshed opportunistically after 24 hours, with the embedded defaults kept as an offline fallback so older installs keep resolving. A newlerd service searchbrowses the store andlerd service presetinstalls a store-only preset, the preset picker in the CLI and web UI merges the local presets with the store index, and a reconcile backfill fetches the definitions of already-installed add-ons on start so their file mounts, families and dashboards survive the move out of the binary. Presets can declare config-file mounts in YAML, inline or via a named generator, which is what keeps phpMyAdmin and pgAdmin auto-login and the RedisInsight and RabbitMQ dashboard proxies working from the store. - Database client tools as host shims (#750). The client tools that ship inside each database service image (
mysqldump,pg_dump,psql,redis-cli,mongodumpand friends) are surfaced as pass-through shims on the host PATH, so you can dump or query a database that lives outside lerd or point an IDE at a real client binary without installing the tool yourself. Each shim runs the tool in a throwaway container built from the service image with your home directory mounted, so output files and CA certs behave like a native binary and the running database container is never touched. A hostless dump adopts the current project's own database service; an explicit host passes straight through.lerd shimslists and toggles them, and each database service gains a Tools tab in the web UI. - TLS certificates renew before they expire (#729).
IssueCertis now expiry-aware and keeps an existing certificate only while it still parses, is valid, and sits more than thirty days from expiry, otherwise it reissues; the watcher boot scan runs a newEnsureCertfor secured sites so an ordinarylerd startheals an aging cert, covering the site's own domains and every worktree wildcard SAN.lerd secure --renewforce-reissues on demand without toggling HTTPS off and on, with a matchingtls_renewaction on the site MCP tool. - DNS switches through dedicated commands (#745). The install-time DNS question is asked once, on a genuine first install, and every later install or update honours the saved choice silently, the same way the Node-management choice does. Flipping the mode moves to three commands:
lerd dns:enableturns lerd-managed DNS on,lerd dns:disableturns it off and moves sites tolocalhost, andlerd dns:repairre-runs the setup to fix a broken but enabled resolver. - Extra host ports per PHP version (#748). Each PHP version carries its own list of extra host ports, published on that version's shared FPM container so
localhost:PORTreaches a process you start inlerd shell, a Vite dev server, a websocket, an ad-hoc listener. A requested port already claimed by a lerd service, another version's list, or any other listener shifts to the next free port. A Ports tab on each PHP version adds and deletes ports as cards,lerd php:ports add|remove|listcovers the CLI, and the MCP runtime tool gainsports_list,ports_addandports_remove. - Insert missing .env keys in place (#717, #734). An anchor-based merge fills in the keys a project's
.envis missing from.env.example, placing each next to the neighbours it has in the example and copying the example value verbatim, purely additively.lerd env:check --fixshows a unified diff and applies it on confirmation; the dashboard Env tab opens a Review keys modal listing each missing key with the value it would get and a checkbox per row, required keys pre-ticked, so you stage exactly the subset you want into the editor with green change bars before saving. lerd cleanupreclaims real disk by default (#739). The deep tier is now the default, for the command and the daily sweep, with a--safeflag to drop back to the conservative provably-lerd-only pass. Deep reaps catalog service images no service references any more and every dangling image, retries removal across passes so a build chain reclaims in one run, skips an image a container still holds, and when everything reclaimable is held by a running container reports how much a restart would free instead of a bare nothing to do.- Remote dashboard address, mobile sign-in, and phone polish (#782, #784). The Remote dashboard access card shows the
http://<lan-ip>:7073address next to a scannable QR of the same URL once LAN exposure is on. Remote access gains a session layer: on the first successful Basic auth the gate sets an HttpOnly, HMAC-signedlerd_sessioncookie valid for a week, so iOS Safari stops re-prompting for the password on every refresh, and rotating the credentials invalidates every session. The web UI picks up a round of phone polish, wrapping toolbars, a slimmer back bar, the framework badge hidden on narrow screens, the open action targeting the LAN share URL on a remote dashboard where.testdoes not resolve, Tinker no longer zooming on iOS focus, and the loopback-only launchers dropping out when the dashboard is viewed remotely. - Themed hover tooltips across the web UI (#780). A shared
use:tooltipaction renders one themed bubble portaled to the body, so tooltips escape the panels and horizontally scrolling rows that clipped the nativetitlelabels, follow the light or dark theme, and clamp to the viewport. Everything that carried atitleflows through it while keeping anaria-label. - The site detail page is a resource view (#793). The Overview becomes a stack of sections rather than one control strip over the logs, a runtime and workers row carrying the PHP/Node pickers, the worker toggles, and the doctor and framework commands as a matched pair, and a grid of service cards each with its category icon, running state, and a button that opens the service's dashboard. Live logs move to their own tab, the project path below the header opens the folder in the host file manager on a loopback dashboard, and the doctor stays worktree aware.
queue:startis derived from the framework definition (#769). Atune_commandtemplate with{queue}/{tries}/{timeout}placeholders and arestart_commandon the framework worker replace the hardcodedphp artisan queue:work, so a non-Laravel framework such as CodeIgniter (php spark queue:work) declares its own flag syntax and the MCPqueue_start/queue_stoppath routes through the same shared lifecycle.- Framework detection resolves offline from the cached store index (#774). The store index is written to a local cache file whenever it is fetched, and the watcher refreshes it at startup and every six hours. Detection now layers an installed store definition, then the cached catalogue, then the built-in adapter, so any framework in the catalogue resolves by name and version without a network round trip rather than only Laravel and Symfony.
- Node management and the mkcert CA announcement are quieter on reinstall (#742, #732). The Node-management choice is remembered so it survives later installs, and install skips the gold "Installing mkcert CA" line and its implied sudo step when the root CA is already in the system trust store, running
mkcert -installas a silent no-op so nothing is lost. - Search the preset picker and service tool cards (#719, #764). The service preset picker filters live by name, description or image with an empty state, every installable preset is promoted into the global command palette under an Install service group, and service tools render as cards.
Changed
- Distribution and CI point at the lerd-env org (#756). The project moved from
geodro/lerdto thelerd-envorganisation, and the release, base-image and Pages references follow. - Go dependencies updated, including the
x/netDoS fix (#766).
Fixed
lerdno longer writes its config block into shared and committed AI files (#792), somcp:injectstops polluting an AGENTS.md or guidelines file that belongs to the project.- The DNS sudoers rule is no longer rewritten on every install (#730), install prompts stop swallowing typed-ahead input (#759), and the macOS
podman-mac-helpersudo prompt is skipped when the helper is already installed (#761). - Host-worker consent is keyed on the command that actually runs (#770), so the v1.27 host-execution gate remembers the approval for the resolved command rather than re-prompting.
- MCP podman hot-path calls are bounded and a stalled machine self-heals (#715), and the web UI check-for-updates button gives live feedback instead of appearing to hang (#786).
- macOS machine heal, DNS teardown, and CA trust check are hardened (#791).
- A large MySQL or MariaDB restore no longer dies with "Lost connection during query". A dump with a single statement above the server's
max_allowed_packetaborted the connection partway through, and the bundled MySQL config never raised it off the image default while MariaDB shipped no config at all, leaving it on the 16MB default that a moderately large row already exceeds. The bundled MySQL config now setsmax_allowed_packetto 256M and the MariaDB preset gains the same, with the service Config tab documented for dumps that need more.lerd db:import,lerd db:restore, and a cross-version service migrate also pass--max-allowed-packeton every SQL-load path so the client is never a second bottleneck once the server ceiling is raised. - v1.27 review follow-ups (#763, #773, #799, #802). Correctness, macOS-compatibility and safety issues found reviewing the v1.27 changes.
Docs
- A
CLAUDE.mdagent guide and contributor-agent skills are added to the repo (#722), and the docs rail icon points at the documentation rather than the landing page (#712).
[1.27.1] - 2026-07-04
Fixed
- Every published port of a service is now movable, and dashboard links follow the port they are served on (#738). A multi-port service (Mailpit's SMTP
1025plus web UI8025, RustFS's S39000plus console9001, Selenium's WebDriver4444plus noVNC7900) could only have its primary port moved, so its other ports were stuck on the defaults and the dashboard link and iframe kept pointing at the old port after any change. Services now carry a per-container-port override so every published mapping is movable from the CLI withlerd service port <svc> <port> --container <cport>, from MCP withcontainer_port, and from the dashboard Ports modal, which lists one editable field per published port. The dashboard link follows whichever port its service is served on, and the port-ownership guard shifts the other ports off host-owned defaults the same way it already did for the primary.
[1.27.0] - 2026-07-01
This release makes service ports something you manage rather than fight, rebuilds the site doctor as a framework-agnostic engine that works for every project, and hardens lerd against the messy realities of unclean shutdowns and podman upgrades. A containerised database now coexists with a host-installed server of the same engine by auto-shifting its published port, published and extra ports are editable from the dashboard and the CLI, and every bundled preset can publish the ports it declares. The Laravel-only doctor becomes a universal baseline plus store-declared per-framework checks, available from the web UI, the TUI, a new lerd site:doctor command, and the MCP diag site_doctor action. lerd auth ssh brings a shared ssh-agent so composer can reach private git repositories, lerd cleanup reclaims the podman disk lerd's own rebuilds leave behind, and FPM auto-starts (or offers to install) instead of dead-ending. Underneath, lerd now self-heals rootless podman after a major upgrade, recovers ghost containers and a lost nginx port after suspend, and routes every podman call through a single seam. A security pass closes the host-execution boundary so an untrusted project can no longer run commands or host workers without consent.
Added
- Published-port management, and coexistence with a host-installed database (#633, #688, #708). When a DB engine's default published port (3306, 5432) is already owned by a host-installed server, lerd publishes its container on the next free port at quadlet-write time, persists the choice, and rewrites the connection URL, so the collision is avoided even at boot autostart where nothing is around to step aside; detection is a port-bindability probe at quadlet-write time, so a host server that holds the default port (on loopback or all interfaces) shifts lerd's container to a free port. A new
lerd service port <svc> [port] | --resetmoves or resets a service's published port with a pre-flight and a host-proxy.envfollow, published and extra ports are editable from a Ports cog on each service page through a single shared serviceops layer the CLI, dashboard and MCP all call, and every bundled preset (not just the default-stack ones) can now publish the extra ports it declares, with its host port autofilled in the modal. - The site doctor is framework-agnostic (#686, #695). The Laravel-only doctor is rebuilt as a universal baseline that runs for every site (env file present, env drift against the example, application key, composer and node install and lockfile state, composer and npm security audits, a missing or empty SQLite database, and the PHP version range) plus each framework's own checks declared declaratively in the lerd-frameworks store through four typed evaluators, so a framework gains coverage by editing the store rather than Go. It is available from the web button, the TUI tab, a new
lerd site:doctorcommand with--json, and the MCPdiag site_doctoraction, with dependency and audit findings exposing an allowlisted Fix button. lerd auth sshfor composer over SSH (#646, #645). A sharedlerd-ssh-agentsidecar holds unlocked keys in memory solerd composercan authenticate to private git repositories, including passphrase-protected keys, with the agent socket on a named volume so it never crosses the host-VM boundary on macOS; the PHP runtime images (FPM and FrankenPHP) gain git and openssh-client so on-disk keys work too.lerd cleanupreclaims orphaned podman disk (#647). A lerd-aware command that only ever removes images provably lerd's own, reaping the PHP rebuilds, stranded base images and superseded service images that accumulate over time, with a safe default tier and an on-demand--deeptier, a--dry-runpreview, and an automatic once-a-day sweep (toggle withlerd cleanup auto off).- FPM auto-starts, or offers to install the PHP version (#652). Running
php, artisan,lerd shell, tinker or thephp:bun/pest:browsercommands with a stopped FPM container now starts it automatically and waits, offers to install a missing version on a real terminal (or lists installed versions to switch to), and returns a clear non-interactive error over MCP instead of a raw podman failure or a macOS-wrongsystemctlhint. - Self-healing rootless podman after a major upgrade (#658). lerd fingerprints the host podman version and network backend, and on the next run after a major-version or backend change rebuilds the lerd bridge in the new format, runs
podman system migrate, and clears the stale rootless-netns before bringing containers up;lerd doctoralso checks for the netavark, aardvark-dns and pasta/slirp4netns network helpers a fresh host can lack. - nginx recovers after the host resumes from suspend (#669). The DNS watcher dials 443 on its slow poll and, after two consecutive down reads on a server it has already seen healthy, restarts lerd-nginx so it rebinds its host ports, so
.testsites stop returning "Secure Connection Failed" after a long suspend without a manuallerd start. - Port and build preflights (#628, #648).
lerd installnow warns about a foreign listener already holding HTTP 80, HTTPS 443 or DNS 5300 before any setup work, and the doctor gains rootless podman build preflight checks. - Framework prune and a remove guard (#624). A
framework prunecommand and an in-use guard onframework removeso a framework still backing a site can't be pulled out from under it.
Changed
- All podman execution routes through a single seam (#631).
internal/podmanbecomes the one component that executes the podman binary, funnelling roughly 145 call sites through commonCmd/Run/CmdContexthelpers with consistent binary-path resolution (so macOS Homebrew and launchd PATH lookups always apply) and error formatting, with a guard against any file outside the package building a podman exec directly. - The service YAML is the single source of truth for custom services (#679). Install state for non-default services now resolves from the definition YAML rather than the quadlet file, so the preset picker, install guard and dependency checks agree with the services list, and a reconciliation pass on
lerd startregenerates a missing unit from its YAML and reaps an orphan quadlet with no backing YAML. - The host-proxy gateway rebind is a single change-gated restart (#696), and a host-proxy dev server binds the reachable host gateway so the proxy container can reach it without publishing the raw dev server on the LAN (#617, #694).
Fixed
- Security: consent is required before running project-supplied commands and host workers on the host (#697). An untrusted
.lerd.yamlcould run host commands with no consent throughhost: trueworkers, an embeddedframework_def, andlerd run/ the dashboard / thecommands_runMCP tool; an importedframework_defis now stripped of its host workers and commands, and a project's own host custom workers and top-level commands are tagged project-origin and gated at the host-execution boundary, prompting once and remembering the approval per site. A poisoned store definition imported by an older build self-heals on the next run. - Ghost containers are recovered on start (#699). An unclean Podman Machine shutdown can leave a container's libpod entry stuck
Createdwhile its storage layer is gone, sopodman run --replacefails and the unit never starts; lerd now detects the signature and purges the stuck containers inside the VM before retrying the start once (darwin-only, filtered tostatus=createdso running services are untouched). - Idle-suspend polish. Suspended host-proxy sites serve an auto-refreshing waking page instead of a dead 502 proxy and restore cleanly once the dev server is back (#673), a host-proxy site is paused when its dev-server worker is stopped rather than 502ing (#670), suspended workers wake on web access on macOS (#681), the in-container worker is reaped on stop so idle-suspend actually frees CPU on macOS (#662), and the idle moon shows in real time on resume (#703).
- macOS and LAN-share fixes. Postgres runs native on Apple Silicon via an imresamu/postgis rewrite (#676), lerd-ui is reachable over TCP on macOS so idle and dump status work (#661), LAN-shared sites that force HTTPS no longer hit
ERR_SSL_PROTOCOL_ERROR(#653), and the LAN-share proxy carries application WebSockets while only diverting Vite's HMR socket (#657). - Watcher, services and diagnostics. Source-file watches are bounded so an asset dump can't exhaust file descriptors (#675), a failed service start surfaces the real reason (#655), the doctor recognises a launchd-managed lerd-dns (#650),
pest:browserboots the Playwright server in the doctor so a boot failure stops false-greening (#682), MCPsite linkhonours host-proxy sites (#641), idle-suspended workers whose marking was lost on reinstall are recovered (#634), and the applehv provider is pinned forpodman machine initon podman 6 (#701). - v1.26 review follow-ups (#689, #668). Correctness and quality issues found reviewing the v1.26 changes.
Docs
- The MCP reference is synced with the current tool surface (#706), and the lerd-nixos flake is documented (#710).
[1.26.2] - 2026-06-26
A follow-up to 1.26.1 that completes the prebuilt-base pull fix for every podman version.
Fixed
- Prebuilt PHP base images are pulled on all podman versions (#638). 1.26.1 gated the base pull's
--policy=alwaysflag behind podman 5.0, butpodman pull --policyactually landed later (it is absent on 5.4 and every 4.x), so podman 5.0 through 5.4 still rejected the flag and fell back to a full local build. The flag is dropped entirely,podman pullalready defaults to thealwayspolicy so it was redundant, and the real pull error line is surfaced when a pull genuinely fails.
[1.26.1] - 2026-06-26
A patch release fixing a silent fallback that made most Linux hosts rebuild every PHP image from source, alongside cherry-picked fixes for macOS LAN exposure, setup feedback, and host-worker reporting.
Fixed
- Prebuilt PHP base images are pulled on podman below 5.0 instead of silently building from source (#638). lerd pulled the base with
podman pull --policy=always, but--policyonly exists on podman 5.0+, so on Ubuntu 24.04 (4.9.3), 22.04 (3.4.4) and Debian 12 (4.3.1) the flag was rejected; the pull's stderr was discarded, so the failure was invisible and lerd quietly fell back to the full local build even when a matching prebuilt base was published. The flag is now gated behind a podman 5.0 check and the pull error surfaces in the fallback message. lerd setupreports npm and bun build failures instead of killing the process (#622). A failed install returns through the feedback step rather than callingos.Exit, so the failure shows in context.- Host workers whose dependencies aren't installed surface a remedy instead of dropping silently (#616), and
lerd update's service restarts route through the feedback layer, warning rather than erroring when a best-effort restart doesn't take. - macOS LAN exposure keeps published ports and no longer double-binds the DNS forwarder (#618, #619). Bind-all IPv6 publish lines are rewritten to
0.0.0.0rather than dropped solan.exposedcontainers keep their ports, and the LAN DNS forwarder is skipped where the host dnsmasq already binds the LAN address. - Docs: the host database override example uses
host.containers.internal(#626).
[1.26.0] - 2026-06-24
This release brings lerd's one-line installer to macOS, rebuilds the dashboard's editors on Monaco with a real PHP language server behind Tinker, reshapes the terminal UI into a clickable tabbed dashboard, and gives the whole CLI a styled feedback layer. The same curl … | install.sh one-liner that installs on Linux now installs on macOS, and lerd update self-updates there too. Every in-browser editor moves from CodeMirror to Monaco, and Tinker's autocomplete, diagnostics, hover, quick fixes and formatting now come from phpantom_lsp analysing the real project rather than static lists, with each statement's executed SQL captured inline and results tagged to the line that produced them. lerd tui becomes a mouse-driven, tabbed Dashboard/Sites/Services interface that mirrors the web UI, and the CLI routes through a shared feedback palette with spinners, aligned summaries, responsive tables and a unified error style that all degrade to plain text when piped. Linking is smarter, an unlinked project is offered the init wizard rather than dead-ending and non-PHP projects are handled, DNS gains pinnable upstream servers and heals across host network changes, postgres-timescaledb joins the presets, and the website moves to its lerd.sh home with a rebuilt landing page. Alongside the features, a focused security pass closes name-injection, root-write and websocket-rebinding holes, and DNS healing, TLS cert swaps, podman startup and MCP output get hardened.
Added
- macOS support in the one-line installer (#549). The cross-platform
install.shmakes the samecurl -fsSL https://lerd.sh/install.sh | bashone-liner work on macOS the way it already does on Linux, detecting darwin and arm64, pulling thedarwin_<arch>release asset, and installing any missing prerequisites through Homebrew without sudo whilelerd installhandles Podman Machine, mkcert, the/etc/resolverentry and the launchd plists. The bash PATH line is written to.bash_profileon macOS,lerd updateself-updates there by downloading the darwin archive and reloading the launchd agents (deferring tobrew upgrade lerdfor a Homebrew-Cellar binary), and the uninstall path boots out the launchd agents and detachedlerd-*containers cleanly. Homebrew stays as an alternative with abrew trustnote. - Monaco editors across the dashboard, with phpantom_lsp behind Tinker (#552). Every in-browser editor moves from CodeMirror to Monaco, the Tinker REPL, the per-site and global nginx overrides, php.ini and service tuning files, and the site
.enveditor, lazy-loaded so the always-on bundle shrinks, with lightweight Monarch grammars for the nginx, ini and dotenv formats. Tinker's autocomplete, diagnostics, hover and signature help now come from phpantom_lsp, a self-contained Rust PHP language server lerd manages on the host alongside fnm and mkcert, so completions resolve the project's real models, relationships, scopes and vendor classes over a new/api/lsp/phpwebsocket; if the server can't be reached the editor still works and code still runs. - A more REPL-like Tinker, with quick fixes, inline SQL, and per-line results (#554). The language server's quick fixes surface as Monaco code actions, so a "Class not found" warning offers an import and accepting a completion carries its
usestatement along, formatting is wired to Shift+Alt+F, the context menu and paste, and each statement's output is tagged with the editor line that produced it. For Laravel runs a query listener inlines bindings and emits every executed query as its own full-width card just ahead of the result it triggered, guarded byclass_existsso plain-PHP REPLs are unaffected. - Clickable, mouse-driven tabbed TUI (#574).
lerd tuiis reshaped around a top tab bar with Dashboard, Sites and Services screens, switchable by click orctrl+left/right, with mouse support throughout, clicking a tab, row, or a dashboard item jumps to its tab with that item selected, and the wheel scrolls whichever pane it is over. The Dashboard is a six-card overview mirroring the web UI with in-context app, service and worker logs, and the interactive accent becomes the brand red across the TUI and the CLI feedback layer. - A styled CLI feedback layer (#568). A new
internal/feedbackpackage is the single source of the lerd colour palette (shared with the TUI) and a set of progress primitives, spinners that resolve to a check or cross in place, a live accumulating line, aligned key/value summaries, styled prompts and an amber warning glyph, all degrading to plain text when stdout is piped, redirected, orNO_COLORis set so the MCP server and scripts keep parsing stable output. The layer is extended across install and start (#604), worktree and service-stop output (#608), and uninstall (#612), and list views render as responsive tables with a unified error style (#610). - Smarter linking for unlinked and non-PHP projects (#558, #562). Running a command in an unlinked project offers to link it through the init wizard instead of dead-ending, and
lerd linkroutes through that same wizard and handles non-PHP projects. - Pin upstream DNS servers (#583). Users can pin the upstream DNS servers lerd-dns forwards to rather than relying solely on the detected system resolvers.
- postgres-timescaledb preset (#572). TimescaleDB joins the bundled preset catalogue as a same-family postgres alternate, installable and selectable like any other preset.
- Updated MariaDB image list (#551). The available MariaDB versions are refreshed, keeping the bare
11tag so existing installs still resolve.
Changed
- The website lives at lerd.sh (#559, #560, #576, #578), served from the custom domain with the install one-liner and documentation links pointed there, a rebuilt homepage as a custom landing page with a live demo (#564), and refreshed social-share image and SEO metadata (#587, #589).
- The geodro to lerd-env org move is self-healing (#602). Distribution links are centralised and the org cutover repairs itself across existing installs, alongside capping the inbound websocket frame payload size.
- lerd-dns stays running across
lerd stop(#571), so name resolution for.testdomains survives a stop, withlerd quitremaining the full teardown that also stops lerd-dns.
Fixed
- Security pass over the post-1.25 changes (#613). Site and worktree-branch names that flow into
site_initshell commands andCREATE DATABASEidentifiers are slugged so a hostile project directory or branch name can no longer break out, the NetworkManager dispatcher's per-userlerd.confrewrite runs throughrunuseras the owning user so a symlink can't trick root into truncating a file, and the dashboard websocket's same-origin fallback now also requires an IP literal or a reserved local TLD so a public domain rebound to loopback is rejected. - DNS heals a stale
lan:exposemapping when the host LAN IP changes (#553), with a PF_ROUTE watcher reacting to host network changes on macOS. - Legacy framework versions are served from the lowest available definition (#582) instead of failing to resolve.
- AI agent detection is forwarded into the container (#555) and guaranteed for MCP-originated commands so in-container tooling sees it.
- Worktree databases are provisioned and realigned on link and env, and TLS certs are swapped atomically (#607, #608) so an nginx reload never sees a missing cert.
- Podman machine start is retried once before failing the install (#606), and versioned DB alternates no longer leak the bare canonical service through a substring match (#605).
- MCP worktree, diagnose, service env and check-updates results are visible to the host (#550) by routing them through content blocks.
- The tray uses a dark icon on light desktop panels (#585).
- Dependabot alerts cleared (#566) by bumping vite and dompurify.
[1.25.0] - 2026-06-17
This release adds activity-driven worker suspension, makes bun a first-class JS runtime, and opens up how a PHP site's image is built. Idle-suspend gracefully stops a site's suspendable workers (queue, scheduler, Horizon, Reverb, the Stripe listener, Vite) after a period of no activity and brings them back on the next request, CLI command, MCP call, or source-file save, with a global toggle, a configurable timeout, and per-site pinning. bun can now drive a site's JS tooling on the host and, opt-in, run inside the PHP-FPM container, switchable from the dashboard, the TUI, or lerd js:runtime. PHP sites gain two ways to shape their image, extra Alpine packages baked in with lerd php:pkg and a fully custom Containerfile, and the FrankenPHP runtime now reaches extension and debug-tooling parity with FPM, so redis, gd, the database drivers, Xdebug, the dump bridge and the Debug window all work under Octane, alongside in-container Pest browser testing on musl chromium and per-project Octane auto-reload. The dashboard grows a Laravel Doctor panel, a Resources widget that accounts for lerd's whole footprint, a Clear logs action, and APP_NAME on the site tiles, RabbitMQ and RedisInsight surface their dashboards same-origin inside lerd-ui, the MCP server gains a logs tool, and the dashboard ships in six more languages. Alongside the features this release hardens the localhost/HTTPS paths, runtime switching, FrankenPHP relinks, and lerd stop.
Added
- Activity-driven worker suspension (idle-suspend) (#525, #540, #541, #542). lerd watches each site for activity (HTTP requests via the nginx access feed, CLI commands, MCP tool calls, and source-file saves) and, after a configurable idle timeout, gracefully suspends that site's suspendable workers, the queue, scheduler, Horizon, Reverb, the Stripe listener, and the host Vite dev server, bringing them back the moment the site is used again. The suspend engine runs inside lerd-watcher, worktrees keep their own per-branch timers, and Vite builds its assets once on suspend so the site keeps serving. It is off by default and managed with
lerd idle on/off,lerd idle timeout <duration>,lerd idle status, andlerd idle pin/unpin <site>to exempt a site, mirrored by a dashboard toggle. Turning it off resumes every suspended worker, and the engine reconciles against reality across installs and restarts so nothing is left stranded. - bun as a first-class JS runtime (#506, #521, #522). bun can drive a site's JS install, dev, and build on the host (auto-selected for bun projects, or when Node is unmanaged and only bun is present), toggled from the site's Node dropdown, the TUI picker, or
lerd js:runtime [bun|node|auto]. Forlerd shelland in-container tooling,lerd php:bun installdrops a musl bun into a persistent/root/.bunvolume that survives image rebuilds and is shared across PHP versions, withlerd php:bun update,lerd php:bun version, andlerd php:bun remove. lerd never version-manages bun; the user installs it andbun upgradeself-updates. - Custom Alpine packages in the FPM image with
lerd php:pkg(#508).lerd php:pkg add/remove/list <packages> [--php version]bakes extra Alpine packages (CLI tools, libraries) into the FPM image, persisted underphp.packagesin config so they survivephp:rebuildand base-image updates, layered onto the shared image rather than the published base. - Serve a PHP site from a custom
Containerfile(#511). A PHP project can ship its ownContainerfilebuiltFROMthe lerd base, so a site that needs a bespoke image is served from it directly rather than the shared FPM container. - In-container Pest browser testing (#518).
lerd pest:browser installsets uppestphp/pest-plugin-browserinside the shared FPM container by baking Alpine's musl chromium into the image, persisting the Playwright registry in a volume, and shimming Playwright's glibc browser to the system chromium, withlerd pest:browser doctorandlerd pest:browser remove. Chromium only, current PHP versions only. - FrankenPHP extension and debug-tooling parity with FPM (#527). lerd builds a derived FrankenPHP image carrying the same runtime extension set the FPM image ships (redis, gd, the pdo drivers, intl, imagick, mongodb, and the rest), so they are present from first boot, and the dump bridge, Debug window, and Xdebug toggle all apply to a FrankenPHP site; only the SPX profiler stays FPM-only.
- Per-project Octane auto-reload (#518, #520). A FrankenPHP worker-mode site can run Octane under a file watcher that reloads workers on code changes, toggled with
lerd octane:reload [on|off]or from the dashboard, kept throughlerd install, the Octane equivalent of Horizon's auto-reload. - Xdebug pause into running workers, plus on-demand mode (#516).
lerd xdebug on --on-demandsetsstart_with_request=triggerso nothing auto-connects, andlerd xdebug pause [site] [--list] [--pid]uses Xdebug's control socket (xdebugctl) to break the IDE debugger into a running queue/Horizon worker, scheduled task, or CLI script. PHP-FPM sites only. - MCP
logstool (#515). An eleventh grouped MCP tool reads every log source (app, FPM, nginx, workers, services) withgrep,since/until,level, andlinesfiltering, so an assistant can tail and search logs without shelling out. - Embedded RabbitMQ and RedisInsight dashboards (#514). lerd-ui proxies the RabbitMQ and RedisInsight management UIs same-origin under
/_svc/<service>/rather than sending you to a new tab, so they open inline like the other service surfaces. - Laravel Doctor (#509, #528). The site detail view and the TUI gain an on-demand Doctor panel that runs the framework's own health checks, sourced from the framework definition, with one-click fixes; it is worktree-aware and only warns on env keys the project actually requires.
- Dashboard Resources widget (#536). A Resources card reports total CPU and memory across lerd's whole footprint, the
lerd-*containers plus lerd's own host-side processes (the UI, watcher, and tray daemons and any host worker such as Vite), with the memory share of host RAM and a ranked list of the heaviest contributors. - Clear logs and
APP_NAMEon the dashboard (#510, #507). The App Logs tab gains a Clear logs button that reports the total log size and reclaims the disk behind a confirmation, and the site overview tiles are titled by a custom LaravelAPP_NAMEwhen one is set. - Six more dashboard languages (#505). The dashboard now ships in fourteen languages, adding Simplified Chinese, Japanese, Romanian, Italian, Polish, and Vietnamese.
- TUI 1.24 integrations (#529). The terminal UI picks up the release's work: a bun entry in the Node picker, a FrankenPHP site's PHP picker limited to runnable versions, idle-suspended workers shown as suspended, the Laravel
APP_NAMEin the site header, an on-demand Laravel Doctor tab, opening a service's dashboard in the browser withO, and the services pane's workers sub-grouped by owning site.
Changed
- A FrankenPHP site's PHP version picker is limited to runnable versions (#523). The dashboard dropdown only lists the versions FrankenPHP publishes an image for, so it never offers one that would silently downgrade the site.
Fixed
- FrankenPHP runs the site's pinned PHP image instead of downgrading (#523), so a site pinned to 8.5 runs that image rather than dropping back to 8.4, and a committed FrankenPHP runtime no longer silently upgrades a site's PHP on relink (#538).
- Workers stay healthy when switching a site's PHP runtime (#524), and a stale FrankenPHP container or orphaned quadlet is reconciled when a re-linked site is no longer FrankenPHP (#534).
- A paused runtime site's container no longer autostarts at boot (#526).
- Consistent localhost and HTTPS handling (#533, #537).
.localhostmode behaves the same acrosslerd init,lerd secure, and the installer, a committed HTTPS choice survives the wizard on a localhost box, and a secured project stays on http when managed DNS is disabled, withSetSecuredgated behind managed DNS so the UI and MCP match the CLI. lerd stopretries systemd jobs reported "canceled" (#532), so it no longer leaves containers running on Linux.- lerd's php shim stays ahead of a global php for node child processes (#539), and FrankenPHP worker quadlets translate to launchd correctly when
Exec=is quoted on macOS (#519). - The Xdebug dashboard toggle applies to FrankenPHP and custom-FPM containers (#538).
- Dependency advisories cleared (#535) by bumping esbuild, vite, and form-data.
- Hardening from pre-release review and smoke testing (#543, #546, #547, #531), covering FrankenPHP downgrades, log filtering, the dashboard service proxy, and idle-suspend reconciliation.
[1.24.1] - 2026-06-11
A fix release for v1.24.0. On macOS a fresh lerd install left every container unable to start: nginx, all PHP-FPM and all service containers failed with statfs /Users/<user>/.local/share/lerd/...: no such file or directory. The Podman Machine was initialised with -v /Volumes:/Volumes to keep external drives mounted, but Podman's --volume is a stringArray whose default set is /Users,/private,/var/folders, and passing any -v replaces that whole set, so the VM mounted only /Volumes and never the host home that every bind mount is sourced from. Init now passes all four required mounts explicitly so the home mount survives. A machine already broken this way can't be repaired by editing its config, because Podman writes the guest mount units once at init via Ignition and never regenerates them on restart, so lerd now detects a machine missing the home mount and recreates it on the next lerd install or lerd start (a machine in this state has no working containers, so nothing is lost). Machines created before the regression kept Podman's defaults and are unaffected, which is why it only surfaced on fresh installs. Linux is unaffected.
Fixed
- macOS containers fail to start on a fresh install because the host home mount was dropped from the Podman Machine (#513). The regression came in with #458, which added
-v /Volumes:/Volumesatpodman machine initto keep external drives reachable; because--volumeis a stringArray, that single-vreplaced Podman's default/Users,/private,/var/foldersset, so the VM lost/Usersand every bind mount sourced from~/.local/share/lerdfailed. Init now passes all four paths explicitly so fresh machines mount the host home from the start, and a machine already broken this way (only/Volumes) is detected and recreated on the nextlerd start. Recreation is required because Podman's guest mount units are baked at init by Ignition and a config edit alone never makes the path appear inside the VM./Volumesis still mounted, so external drives keep working.
[1.24.0] - 2026-06-09
This release reworks how AI assistants talk to lerd and broadens what the dashboard shows. The MCP server collapses its roughly eighty flat tools into ten resource groups selected by an action argument, which roughly halves the per-session manifest and keeps it static so it no longer changes shape mid-session, and lerd now registers with Codex CLI, Gemini CLI, GitHub Copilot and Google Antigravity alongside Claude Code, Cursor, JetBrains Junie and Windsurf, all reading one shared, single-source tool reference. The Sites tab gains an overview dashboard when no site is selected, mirroring the redesigned, category-grouped Services dashboard, sites can be grouped under one base domain with shared or separate databases, and the list can be sorted and drag-reordered. Node and other non-PHP projects can run as host-proxy sites where lerd supervises the dev server on the host and nginx proxies to it, databases can be moved between same-family services with db:move, and soketi, OpenSearch, RedisInsight and Beanstalkd join the preset catalogue. The Stripe webhook listener now works for any framework with a configurable path, Horizon can auto-reload in development, and frameworks can declare unconditional env.vars defaults. Alongside the features, this release hardens the systemd and quadlet generation against directive injection from a cloned repo, gates host-proxy dev commands behind explicit consent, and stops lerd stop from raising spurious worker heal notifications.
MCP integrations: the tool surface is a breaking change. The old flat tool names (
sites,artisan,db_set, …) no longer exist; call the matching group with anactioninstead (for exampleexecwithaction: "artisan", ordbwithaction: "set"). Re-runlerd mcp:enable-global(orlerd mcp:inject) so your assistant picks up the new manifest and reference.
Added
- Grouped MCP tool surface and four more assistants (#497, #498). The MCP server exposes ten resource-grouped tools,
site,service,db,env,runtime,worker,exec,framework,diagandworktree, each driven by anaction, routed through one dispatch table that reuses every existing handler, which cut thetools/listpayload from roughly thirty-two thousand bytes to sixteen and made the manifest static so framework-specific actions no longer toggle the tool set mid-session. Client registration is table-driven: Codex CLI (~/.codex/config.toml), Gemini CLI (~/.gemini/settings.json), GitHub Copilot (~/.config/Code/User/mcp.json, VS Code'sserverskey withtype: stdio) and Google Antigravity (~/.gemini/config/mcp_config.json) are now supported next to Claude Code, Cursor, Junie and Windsurf, and every assistant renders the same single embedded tool reference so the guidance can't drift between them. - Sites overview dashboard (#492, restored in #501). When no site is selected the Sites tab shows an overview instead of a bare prompt: a running-versus-total count, a paused tally and a failing-workers indicator, with sites laid out as click-through tiles grouped by framework plus Other and Paused buckets, each showing the favicon or status dot, the domain with a TLS lock, a framework and PHP or Node subline, worktree and worker indicators, and an open-in-browser shortcut. The chrome shared with the Services dashboard is extracted into reusable header, section, site-icon and worker-indicator components so the row and tile views stay in sync.
- Redesigned services dashboard with categorized discovery (#491). The Services tab is reorganised into categorized sections with preset cards and a suggestion banner so installable services are discoverable at a glance.
- Site groups (#484). Related sites can be grouped so a main owns a base domain and the rest occupy its subdomains, with an optional shared database per secondary, managed from a grouping modal on the site header and the
siteMCP tool'sgroup_*actions. - Sortable, drag-reorderable sites list (#486). The sites list can be sorted, and dragging a site switches to a manual order that is persisted.
- Host-proxy sites for non-PHP dev servers (#477, #487, #494, #495). A project can declare a
proxycommand in.lerd.yamland lerd supervises that dev server on the host while nginx reverse-proxies the.testdomain to it, with an auto-assigned free port, git-worktree support, and explicit consent before lerd runs a command from a project file. db:movebetween same-family services (#488).lerd db:move --from <a> --to <b>relocates one or more sites' databases between two installed services of the same family (for examplepostgrestopostgres-18), dumping from the source, creating and restoring on the target and repointing each site's.env, leaving the source data intact.- soketi, OpenSearch, RedisInsight and Beanstalkd presets (#489). Four services join the bundled preset catalogue, installable like any other preset.
- Framework-agnostic Stripe webhook listener (#490, #493). The Stripe listener works for any project, not just Laravel: the secret is read from
STRIPE_SECRET,STRIPE_SECRET_KEYorSTRIPE_API_KEY, and a config control sets the forward path in.lerd.yamlso a non-Laravel route works. - Horizon auto-reload for development (#471). Laravel Horizon can run under a file watcher that reloads workers on code changes, toggled per project from the dashboard.
- Unconditional framework
env.varsdefaults (#483). Framework definitions can declare top-levelenv.varsthat are applied as defaults onlerd env, seeded only when the key is absent so a value set directly in.envis preserved, with.env.lerd_overrideand detected-service values still taking precedence.
Changed
- MCP tools are grouped, not flat (#497). The roughly eighty individual MCP tools are consolidated into the ten grouped tools above. This is a breaking change for anything that called the old names directly; see the note at the top of this release.
Fixed
- Reject inputs that could inject systemd unit or quadlet directives (#495, #500). Worker unit generation refuses a newline or NUL in the command at the generation boundary, so the boot and restore path is covered and a cloned repo's
custom_workersentry can't inject a second systemd directive, and custom-service validation runs again at the quadlet boundary afterdynamic_envresolves so inline services and dynamic env values are checked too. - Host-proxy dev commands stay behind consent on every path (#500). The watcher's worktree setup and
lerd unpausenow honourhost_proxy.disabledand only run the command approved at link time, so a command edited into or shipped by a cloned.lerd.yamlis never run unattended. lerd stopno longer raises worker heal notifications (#500). A lifecycle marker records an intentional shutdown, so the health watcher suppresses heal notifications, the dashboard banner and the heal surfaces for workers that lerd itself stopped; the workers stay enabled and come back on the next start, and a stop-then-reboot autostart resumes detection.db:movekeeps an explicitdb.serviceblock in sync (#500), so laterlerd dbcommands resolve the service the site was moved to rather than the old one, and clears the block for sqlite which has no container service to target.- Host-proxy worktree ports and teardown (#500). Worktree dev-server ports are reserved against collision, and unlinking sweeps per-worktree worker units by name so the watcher's stale-site prune tears them down even after the project directory is gone.
- Debug window events are separated by worktree (#481), so a worktree's captures no longer mix into the parent site's view.
- MariaDB readiness and Vite worker boot ordering (#479, #480). MariaDB is probed with
mariadb-adminrather thanmysqladmin, and host Vite workers are ordered after the FPM container at boot so wayfinder doesn't run before PHP is up. - Recover from podman overlay-storage corruption on macOS (#476).
lerd startdetects the overlay-storage error and heals it, resetting the Podman Machine with a sized memory allocation when needed. The heal is gated to darwin with a tightened matcher so it never fires on Linux. - The dashboard
.enveditor scrolls to the bottom (#485), instead of cutting off its last lines on longer files. - Key generation runs through the framework console (#482), instead of a hardcoded
artisancall, so non-Laravel frameworks generate their app key correctly. - Untranslated UI strings (#502). The site grouping modal, the command palette's generated labels, and the command run modal and dropdown are translated across all seven non-English locales; the dashboards already shipped their own translations.
[1.23.1] - 2026-06-04
A fix release for v1.23.0. Installing Postgres from the dashboard failed on Apple Silicon with podman pull ... exit status 125, and left a broken service registered behind it. The quadlet already pinned --platform=linux/amd64 so the container ran under emulation, but the explicit pull step that 1.23.0 added to surface progress ran a bare podman pull with no platform, and since postgis/postgis (like mysql:5.7) publishes no arm64 manifest for any tag, podman couldn't choose an architecture from the manifest list and exited 125 while the run path was happy. Pull and run now key off one shared predicate so they always agree on platform, and the streaming install pulls the image before it writes any config or quadlet, so a failed pull no longer leaves a service registered with no working image. It also fixes the dashboard's admin-tool banner, which only ever appeared on the bare postgres or mysql service and never on a versioned install like postgres-17. Linux and Intel macs are unaffected.
Fixed
- Postgres install on Apple Silicon, with no half-installed service on a failed pull (#473). A new
PlatformPullArgsinjects--platform=linux/amd64for the amd64-only upstream tags (postgis/postgis,mysql:5.7) on darwin and is a no-op on linux, and every pull path now routes through it, the three pull functions in the podman package, the FPM base pull, and the two CLI start and warm-up pulls, so the platform the quadlet runs with and the platform the pull fetches are decided by the sameimageLacksArm64predicate and can't drift apart by service name. Separately, the streaming install now resolves the preset, pulls the image, and only then registers it (config plus quadlet), because quadlet presence is the install-state truth: a failed pull used to write the YAML and quadlet first, so the service showed as installed but never started. - The admin-tool banner shows on any database service, regardless of version or family member (#474). The dashboard offers pgadmin for Postgres and phpmyadmin for MySQL through a banner on the database service, but the suggestion was keyed on the exact service name, so a multi-version install like
postgres-17or a MariaDB never got one even though the store already had adetectServiceFamilyhelper that resolves them back to the right family.suggestionForandsuggestedPresetFornow route through that helper, the wayadminServiceForalready did. The matching gap was on the install side: an admin tool hard-depends on the bare family service (pgadmin onpostgres, phpmyadmin onmysql), soMissingPresetDependenciesrefused to install it on a versioned-only host. A family dependency is now satisfied by any installed member of that family, and by any installed member of a sibling family the tool co-discovers viadiscover_family, so apostgres-17covers pgadmin'spostgresdependency and a MariaDB covers phpmyadmin'smysqlone, matching what they already auto-connect to. The generatedservers.json,pgpassandPMA_HOSTSalready enumerate every family member with matching credentials, so the admin tool can browse all of them immediately after install.
[1.23.0] - 2026-06-03
This release moves lerd's whole configuration surface into the dashboard. Every file a developer used to open in an editor, a project's .env, a per-version php.ini, a site's nginx override, and a service's runtime tuning, is now editable in the browser, each through the same snapshot, optional timestamped backup, atomic off-glob write, validate and auto-rollback pipeline so a save that breaks the config restores the previous bytes and tells you it did. The System pages are tabbed, PHP versions install and remove from the dashboard with a live build log, and every editor is also reachable from the CLI and from MCP. The dump bridge becomes a full Debug window: a first-party Zend extension captures SQL queries with their real bindings alongside mail, views, events, jobs, cache and outgoing HTTP, grouped per request with N+1 detection, and the same window lands in the dashboard, the TUI and an analyze_queries MCP tool. The terminal UI grows a system view, a dashboard mirroring the web home page, modal overlays, toasts and a command palette. And Windows users get a dedicated WSL2 guide with lerd wsl:setup.
Added
- Editable
.envfiles in the dashboard (#434). The site detail panel gains an Env tab with a dropdown across env variants (.env,.env.local,.env.testing,.env.example), a CodeMirror editor with dotenv highlighting and line numbers, an opt-in backup checkbox that timestamps the prior contents as<file>.bkp.<YYYYMMDD-HHMMSS>, and a revert that restores the most recent backup over the file, peeling backups newest-first on repeated reverts. Writes are atomic and preserve the on-disk file mode. The CodeMirror bootstrap Tinker already carried was extracted into a sharedCodeEditorprimitive that both editors now use, and the new/api/sites/{domain}/envPUT and backup subroutes inherit the same loopback gate that already covered raw.envcontent. - Per-version
php.iniediting and tabbed System pages (#436, #443, #448). The System dashboard folds its one-row-per-PHP-version list into a single PHP row whose detail page is tabbed: a worktree-style strip selects the version with a default star and a running dot, and the body splits into Logs, Sites and Config, where Config edits the per-version98-user.iniand restarts FPM on save. Nginx gains a matching Logs and Config pair, and DNS and Watcher render their log viewers directly. A browser-style plus button installs any supported PHP version not yet present, building the FPM image server-side while streaming the build log live into the modal so closing it doesn't cancel the build, with a notification when it finishes; removing a version goes through a confirmation that warns when sites still use it. The global http-level override and thephp.iniboth save through the shared snapshot, optional backup, atomic off-glob write, validate and rollback pipeline. - Nginx config editing from the address bar, per site and per worktree (#437, #440, #445). A sliders button at the end of a site's address bar opens a modal that edits the per-site
custom.d/<domain>.confoverride, and a global http-level editor on the System nginx page editshttp.d/zz-lerd-user.conffor gzip, proxy buffers and the like. Worktrees get their own override, seeded once from the main branch then independent, and the editor scopes to whichever worktree tab is active. Saves runnginx -tinside the container before the new bytes land, roll back to the snapshot and surface the captured stderr when validation fails, and leave an innocent save alone when the failure points at a pre-existing broken neighbour vhost. The same read, edit and reset are reachable fromlerd nginx show/edit/reset(with--branchto target a worktree) and asite_nginxMCP tool. Underneath all of this a newcfgeditservice owns atomic staged writes, timestamped backups, snapshot and rollback and a process-wide save lock, and the per-site nginx, global nginx,php.iniand env editors all route through it, pulling a few hundred lines of duplicated logic out of the web server. - User-editable runtime tuning for every service family (#429, #438, #439, #441, #442, #453). Each service exposes a runtime tuning override that lerd seeds once with a commented template and never overwrites afterward, bind-mounted after the bundled preset config so any value set there wins, editable from
lerd service config <name>, a Config tab on the service detail panel, and aservice_configMCP tool. mysql and mariadb land first through their auto-includedconf.d; redis and postgres load no config by default, so the family declares a container command that points the image at the file, and becauseinclude_diris rejected via-c, postgres starts against a small lerd-managedconfig_file=wrapper that includes the cluster's ownpostgresql.confand then the override directory. Custom services opt in with atuning:block on their YAML carrying the mount target, the seeded template and an optional command, so lerd doesn't have to recognise their family. The web editor mirrors the env and nginx tabs with a save-and-backup modal, a restore diff and a Reset to bundled defaults, and a save that crashes the service auto-rolls back to the prior bytes and reports "save reverted, prior config restored" rather than a bare "service did not become ready" that reads as if the service is still broken. - The dump bridge becomes a full Debug window (#462, #464, #468). A first-party Zend extension,
lerd_devtools, captures SQL queries at the PDO layer with their real bindings, whether the app passes them toexecute()or binds them one at a time, and framework-agnostic seams capture outgoing mail, rendered views, dispatched events, queued jobs and outgoing HTTP; Laravel keeps the richer data from its in-app adapter and the agnostic seams stand down while it's active so nothing is captured twice. Every kind ships over the same socket and ring as dumps and renders as sibling tabs in one Debug view, grouped per request with N+1 detection and slow-query flags, and the dump bridge and collector share one enable sentinel so a single toggle arms the whole window, renamed the Debug bridge throughout the dashboard, tray, TUI, CLI and docs. The extension now compiles in the prebuilt base image instead of on each user's machine at install time, with its source hashed into the Containerfile so an extension-only change still drifts the image and existing users rebuild. Ananalyze_queriesMCP tool returns the N+1 and slow-query report with the originating file and line,dumps_recentgains a kind filter, and the TUI's Debug view gets the same eight lenses (Dumps, Queries, Jobs, Views, Mail, Cache, Events, HTTP) switched with[and], with per-lens buffered counts and a worker-capture toggle onw. - A system view, dashboard and modal layer for the TUI (#417). The terminal UI gains a System pane (
Y) covering DNS, nginx, watcher, notifications, the Debug bridge, per-version PHP with Xdebug, Node and worker mode with reversible toggles that route through the lerd CLI so the TUI shares the same code paths as a manual invocation, and a Dashboard pane (F) mirroring the web home page with a hero status line, an overview roll-up, a system health block and a resources block backed by a newinternal/statspackage shared with lerd-ui through a singleflighted podman-stats cache. The command palette, the PHP/Node version picker, the help reference and a new destructive-action confirm render as centered modal overlays; toasts replace the disappearing status bar with per-kind TTLs and a dismiss key; the dumps view picks up search and context chips; and the services pane groups into Core, Custom and Workers with the header pill naming the failing units instead of just counting them. - Personal
.env.lerd_overridefor per-project overrides (#469). A personal, never-committed.env.lerd_overrideat the project root lets each developer override the values lerd writes into a project's.envwithout hand-editing it after everylerd env. EveryKEY=VALUEis layered on last so it wins over lerd's defaults and computed values likeDB_DATABASEandAPP_URL, written verbatim so quotes are preserved, and the one reserved key,LERD_EXTERNAL_SERVICESwith a comma-separated list, marks services lerd should write connection vars for but not start or provision, which is how a project keeps using a developer's own local postgres while lerd manages everything else. A newlerd env:overridecommand scaffolds the file from a commented template and makes sure it's gitignored, anenv_overrideMCP tool exposes the same flow, andlerd env:checkskips the file since it's a partial overlay. A build guard now fails if any registered MCP tool is ever missing from the injected skill and guidelines again, after nine tools were found undocumented. - Windows (WSL2) getting-started guide and
lerd wsl:setup(#432). A dedicated page covers installing lerd on Windows through WSL2: enabling systemd in/etc/wsl.conf, mirrored networking in.wslconfig, theevents_logger = "journald"tweak incontainers.confwithout which every dashboard log pane shows a podman log-driver mismatch error, keeping projects in$HOMEso bind mounts don't route through 9P, masking lerd-tray since WSL2 has no tray host, and exporting the mkcert root CA to the Windows trust store sohttps://*.testis trusted by Chrome and Edge. Both.testand.localhostDNS modes are presented as viable, validated by a community user on the issue thread, and WSL flag support ships as beta. - Check for updates on the service header (#418). A Check for updates action on each service header forces a fresh registry lookup instead of waiting for the cached availability snapshot to age out, dropping both the 30-second in-memory and 6-hour on-disk registry-tag caches before recomputing and surfacing the outcome (already up to date, update available with the new tag, or check failed) in a toast.
POST /api/services/{name}/updatesis now the trigger; GET still serves the cached value so snapshot rebuilds stay cheap.
Fixed
composer requiresurvives cold container boots (#450).composer require/installdied with "exceeded the timeout of 300 seconds" while runningartisan package:discoveras a post-autoload-dump script, because every artisan command boots the full application and on a cold container filesystem a heavy provider tree, reliably reproduced once Reverb is installed, legitimately runs past composer's stock 300s. lerd now injects a 1800sCOMPOSER_PROCESS_TIMEOUTinto every path that runs composer inside the FPM container: thelerd composerCLI, thecomposerMCP tool, and the setup-time autocomposer install. Closes #449.- Long worktree branch names no longer take nginx down globally (#456). A worktree with a long branch name emits a
server_namethat overflows nginx's defaultserver_names_hash_bucket_size: 64, so nginx fails to start and every site goes down, including the dashboard. Thehttp {}block now carriesserver_names_hash_max_size 1024andserver_names_hash_bucket_size 128at the source so every generated config has it. Fixes #455. lerd startstops flagging its own port owners as conflicts (#461). On macOS the preflight printed false port-conflict warnings on a healthy machine, for:5300on every start and for service ports after any stop/start cycle, even though.testkept resolving and the stack started fine. lerd's own dns and gvproxy port owners are no longer reported as conflicts, so the warning stops crying wolf and training users to ignore it.- A site's custom nginx override follows a domain rename (#444). Renaming a site's primary domain through the UI silently dropped its custom nginx config: lerd deleted the old vhost and generated a new one including
custom.d/<newdomain>.conf, a file that never existed, so the user's directives vanished from the live site while the old file sat orphaned on disk.RegenerateSiteVhostnow moves the override and its timestamped backups to the new name whenever the primary changes, never overwriting backups, andlerd migrate-tlddoes the same for every site whose primary domain shifts when the TLD changes. .envAPP_URLsyncs when the primary domain changes in the UI (#435). The web UI's domain handlers regenerated the vhost, reissued the cert and reloaded nginx but never touched the project.env, so renaming the primary domain leftAPP_URLand the reverb keys pointing at the old host; the CLI already had the guard but the UI is the only surface that can rename a domain in place. A newsiteops.SyncEnvIfPrimaryChangedseam both surfaces call rewrites the parent.envand each worktree'sAPP_URLto the new host, collapsing five copies of the same conditional.- PHP-only controls hidden on static sites (#446). A static site is just a public dir of HTML served by nginx with no PHP, but the detail panel showed every PHP-only surface because the UI gated on
php_version, which lerd populates from the global default even for non-PHP projects. A properuses_phpsignal, a detected framework, acomposer.json, a top-level.phpfile or apublic/index.php, and never a custom container, now lives inphp.SiteUsesPHPand gates the version dropdown, the Tinker and Dumps tabs and the FPM logs;RestartSiterefuses a static site rather than bouncing the shared FPM container every PHP site on that version depends on, and Xdebug moves to a bug-icon toggle in the address bar. - Installed-detection unified on quadlet presence (#433). The MCP
service_preset_list, the web UI presets endpoint andlerd service presetanswered "is this installed?" by probing for the YAML in~/.config/lerd/services/, while the rest of the codebase probes the quadlet, so an orphanlerd-mysql.containerwith nomysql.yamlread as a running MySQL the MCP layer insisted was not installed. A canonicalserviceops.ServiceInstalledprobes the quadlet because that's what podman actually runs, and every presence check across MCP, the web UI, the CLI, the TUI and thelerd checkvalidators routes through it, with thelerd checkfailure path now suggesting the concrete remediation command. - macOS mounts system directories to the Podman machine on first init (#458). lerd now mounts the system directories during the initial Podman machine initialization on macOS, so the path no longer depends on a later run to wire them up. Closes #452.
- Pre-1.23 review follow-ups (#459, #467). A sweep of everything merged since 1.22.1 fixed: the config editor's atomic writer staging a
.tmpthat matched the nginx include glob, now a dot-prefixed temp throughos.CreateTemp; a data race clearing the TUI dump buffer from atea.Cmdgoroutine, now routed through a message applied on the update loop; a first-time global nginx save on an install predating thehttp.dmount reporting success while the container crash-looped, now it waits for the container and validates withnginx -t, rolling back on failure; an upgrade writing a custom service's tuning mount without restarting the running container;.envand service-tuning restore ignoring the previewed backup and always restoring the newest; a tuning auto-rollback showing the raw error with no sign the old config was back; a notifications toggle for the possible N+1 query warning that previously always fired; the worker-queries switch now hiding already-buffered worker queries rather than only stopping future capture; and a stray NUL byte innotify.tsthat had been making git treat the file as binary.
Security
- Cross-origin requests to the dashboard are blocked (#447). Any page open in the developer's browser could fire a CORS-simple
text/plainPOST tohttp://127.0.0.1:7073and reach state-changing endpoints, including the per-site tinker route that runs arbitrary PHP in an FPM container with the project.envand host.sshmounted, since loopback bypassed every existing check and a simple POST carries no preflight and no custom header. State-changing methods now have to prove they came from lerd's own dashboard: a request passes when the browser labels it same-origin, same-site or none, when it's cross-site but carries one of lerd's own origins, or, for clients that send noSec-Fetchheaders at all, when it carries a non-emptyX-Lerd-CSRFheader that a forged cross-origin request can't set without a preflight lerd only answers for its own origins. The dashboard injects the header automatically on unsafe requests; the unix socket, the remote-setup bootstrap, the mailpit webhook, the notify bridge and the non-destructive per-site unpause keep their own source gates and are exempt. - vitest bumped to v4 (#466). Dependabot flagged vitest 3.2.4 as critical (GHSA-5xrq-8626-4rwp, arbitrary file read and execute when the Vitest UI server is listening), a dev-only dependency lerd never runs the UI server for, so real exposure was minimal; the fix lands in 4.1.0 so this moves to
^4.1.8, and regenerating the lockfile pulled the semver-safewspatch that cleared the remaining moderate advisory, leavingnpm auditclean.
[1.22.1] - 2026-05-27
A fix release polishing v1.22.0. The PHP-FPM image rebuild path is now self-healing after a brew upgrade, with an OCI label on every built image so a stale cache file can't strand users on the old image. Podman older than 4.5 fails install with an actionable error instead of a raw cobra panic, and the requirements page lists the affected distros (Ubuntu 22.04, Zorin 17, Debian 11 and 12) alongside the per-distro workaround. The nginx catch-all _default.conf survives hand edits across lerd start thanks to a hash sidecar lerd stamps when it writes the file, and the rewrite itself is now atomic and mode-preserving. lerd dns:check no longer false-alarms on hosts running a sibling dnsmasq, lerd lan expose pre-flights the forwarder port, and lerd service reinstall is transactional and routes through the preset name so a failed reinstall no longer wipes the old install before failing. macOS gains a gvproxy port-forward heal after a Podman Machine restart, the tray Quit menu item actually stops services and exits, and the tray surfaces a stderr warning when the lerd binary can't be located under launchd. The TUI's update-check banner stops false-firing on stable installs.
Fixed
- PHP-FPM images rebuild when the Containerfile changes (#415).
lerd install(and the install pass insidelerd update) used to advance~/.local/share/lerd/php-image-hashwithout doing the build, becauseensureFPMQuadletno-opsBuildFPMImagewhen the image already exists but unconditionally stamped the current Containerfile hash. After abrew upgradethat introduced template changes (SPX in v1.22.0), the on-disk hash matched the new template even though the image was still the old one, soNeedsFPMRebuildreported "up to date" and the rebuild was silently skipped.StoreFPMHashnow runs insidebuildFPMImageonly after a real build completes, so no path advances the hash without actually rebuilding.lerd installalso re-checksNeedsFPMRebuilditself and re-execsphp:rebuildwhen a drift is detected, sobrew upgrade && lerd installis self-healing without needinglerd updateto run the same check afterwards. The rebuild is gated onautostart enabledso it does not re-start units that the user explicitly stopped, and the parallel per-version builds serialise their hash writes through a mutex so they can't truncate each other. - OCI labels on PHP-FPM images detect a poisoned hash cache (#427). The previous fix corrected the path that advanced the cache, but couldn't auto-heal users already in the poisoned state: their cache file already matched the new Containerfile, so
NeedsFPMRebuildreturned false and the rebuild was still skipped. Every PHP-FPM image lerd builds now carries adev.lerd.fpm.containerfile-hashOCI label recording the hash it was actually built from.NeedsFPMRebuildchecks both signals: cache file versus embed (the fast path) and each installed image's label versus embed (the recovery path). Empty labels (images built by pre-fix lerd binaries) are treated as mismatches, so the firstlerd installafter the upgrade triggers a one-time rebuild that stamps the labels. From then on the labels are authoritative and the cache file is just a fast-path optimisation. - PHP-FPM rebuild check no longer scans orphaned legacy images (#430). The label scan introduced above used to iterate every
lerd-php*-fpm:localimage on disk, including pre-v1.22.0 images for PHP versions the user had since removed. Those orphans carry no containerfile-hash label, so the comparison always failed andlerd installre-fired the rebuild banner on every run, even after a successful rebuild of the active versions.NeedsFPMRebuildnow reads the active version list fromphpDet.ListInstalledand only checks labels on those versions' images. A centralisedFPMImageNamehelper keeps the rebuild check and the build paths agreeing on the naming convention, and aContainerfileHashread error now forces a rebuild instead of being silently treated as up-to-date. _default.confsurvives hand edits acrosslerd start(#425).EnsureDefaultVhostrewrote the catch-all conf on every start, clobbering any local patches. Users tuning the catch-all (e.g. swappingssl_reject_handshake onforoffon a staging machine) had to re-apply the change after every restart. lerd now stamps a sentinel_default.conf.lerd-managed-hashalongside the conf with the sha256 of what it last wrote. On subsequent runs the on-disk content is hashed and compared: a matching hash means lerd updates the file in step with template changes, a mismatch means lerd preserves your edit and logs a one-line INFO that it skipped the rewrite. A missing sentinel with matching content reclaims management (recovers from a transient FS failure on the sentinel write), a missing sentinel with diverging content is treated as user-managed with a message that explicitly calls out the upgrade case and tells you the exactrmto restore lerd's default. The sentinel filename suffix stays outside nginx's*.confinclude glob so it never gets loaded as config._default.confis written atomically and preserves file mode (#430).EnsureDefaultVhostused two back-to-backos.WriteFilecalls for the conf and its sentinel, so a crash between them left lerd-managed content with no sentinel, indistinguishable from a user-edited file on the next run. An in-place truncate also briefly exposed half-written nginx config to a concurrent reload. A newwriteFileAtomichelper writes to a sibling.tmp, chmods to the desired mode to defeat umask, then renames atomically into place. Crashes mid-write leave the destination at its previous state, never partial. When the destination already exists, the helper preserves its mode so an out-of-band chmod survives the rewrite.- Clear error when podman is older than 4.5 (#421).
podman network create --dnswas added in podman 4.5 (April 2023). Older releases used to faillerd installwith a raw cobra "Error: unknown flag: --dns" and no hint about what to do next. The install path now translates that specific stderr signature into a message that names the minimum supported version and points at the requirements docs. The substring match is anchored to bare--dnsfollowed by a newline so flag names sharing the prefix don't trip the rewrite, and the wrap usesfmt.Errorf %wso callers (including theErrNetworkNeedsMigrationsentinel on the migration path) still see the original error viaerrors.Is. Refs #420. lerd dns:checkrecognises a legacy host-side resolver (#426). The diagnostic reported "lerd-dns container not running" as a hard FAIL whenever the container was absent, even if a host-side resolver (Homebrew/launchd dnsmasq, system package) owned:5300and was answering the TLD correctly. The top-line summary read "DNS is NOT working" when DNS was in fact working perfectly, just not through lerd. Rung 1 now distinguishes three sub-cases when the container isn't running: port:5300in use and answering.testwith127.0.0.1reads as WARN "host-side resolver is handling DNS; lerd isn't managing it", port:5300in use and answering with a different IP also reads as WARN (a LAN-IP setup for cross-device testing, with the actual answer surfaced in Detail), and port:5300free or held by a non-DNS process reads as FAIL with the probe error in Detail so NXDOMAIN, timeout, and refused stay distinguishable. Hints now emit on WARN as well as FAIL so users see the remediation suggestion in both cases. Reported in #414.lerd lan exposepre-flights the forwarder port (#424).lerd lan exposeused to install lerd-dns-forwarder (listening onlanIP:5300) without checking whether the address was already taken. On hosts running their own dnsmasq via Homebrew/launchd or a sibling tool, the new unit would write fine and then both daemons would race for the same address on every boot. Pre-flight now tries to bind UDP and TCP onlanIP:5300and refuses the install if either fails, with an lsof line (macOS) orss -tulpnhint (Linux) identifying the holder. The check is skipped when lerd's own forwarder is already active (re-runs oflerd lan exposereplace themselves cleanly), and also when launchd or systemd report the unit asdeactivating, so a quicklerd lan unexpose && lerd lan exposedoesn't catch our own forwarder mid-stop and report it as the conflict. Reported in #414.lerd service reinstallis transactional and routes via the preset name (#423).lerd service reinstall <name>had two failure modes that left a service in a half-broken state. First, the custom-service path was passing the SERVICE name (e.g. "mariadb-10-11") toInstallPresetStreaming, which expects the PRESET name (e.g. "mariadb"), so the call failed with "unknown preset mariadb-10-11" afterRemoveServicehad already deleted the YAML and container, leaving the user with neither the old install nor a fresh one.CustomService.Presetis now consulted as the breadcrumb to the source preset and routed throughspec.presetName. Second, configuration errors (missing preset, bad version, unsatisfied deps) and network errors (image registry unreachable) fired AFTERRemoveServicehad run. A pre-flight pass now replays the install path's checks without side effects (preset load, version resolve, missing dependencies, plus a "version-on-single-version-preset" sanity check) and runs the image pull beforeRemoveService, so on-disk state stays intact when validation or the pull fails. Default-preset and custom-service paths share the same validation so both fail closed under the same conditions. A separate fix suppresses the family-consumer regen pass during the reinstall's remove step (it was rendering a partial plist that excluded the service being reinstalled, which on macOS could race with the post-install regen and leave consumers pointing at the wrong env), with explicit regen restored on the install-failure path so a failed reinstall doesn't strand consumers pointing at a deleted container.- gvproxy port forwards heal after a Podman Machine restart on macOS (#422). After a Podman Machine restart (
machine stop/start, a Mac reboot, or a crash), lerd-* containers come back up inside the VM via--restart=always, but gvproxy on the host loses its port-forward table and only learns about forwards whenpodman run -pis invoked from the host CLI. The result was sites that looked "running" but refused on:80/:443, with nolerd start,service restart nginx, or launchd kick fixing it on its own. lerd now records the machine'sLastUpat every successful start and install, and compares it on the next run. When the value moved (external restart) it force-removes running lerd-* containers so the StartUnit pass that follows invokespodman run -pfresh from the host and gvproxy re-registers the forwards. TheLastUpis sampled beforeensurePodmanMachineRunningso internal stop+starts (rootful or memory adjustment) don't trigger a phantom heal. The same pattern runs fromlerd installso users reaching container-start vialerd updateget healed too. Linux is unchanged: every function is a no-op since podman runs natively without a VM or gvproxy. - macOS tray Quit actually exits and stops services (#419). Two bugs combined to make the tray's Quit menu item appear to do nothing. The launchd plist for lerd-tray (and lerd-ui, lerd-watcher) was generated with a bare
KeepAlive=truewhenever the systemd unit hadRestart=on-failure, so launchd respawned the job on any exit including a cleanos.Exit(0), which is not whatRestart=on-failuremeans.Restart=on-failurenow maps toKeepAlive: SuccessfulExit=falseinstead, andRestart=alwaysstill emits the bareKeepAlive=true. Separately,handleQuitranexec.Command("lerd", "quit")but launchd's job environment has no PATH covering/opt/homebrew/bin(where the Homebrew-installed lerd binary lives) so the call silently failed and none of the containers Quit was supposed to stop got stopped. The lerd binary path is now resolved once per call viaexec.LookPathplus a known-prefix fallback, and every tray-sidelerd …invocation routes through a small wrapper. Re-resolving on every call (instead of caching) keeps the tray correct across mid-session reinstalls. - Tray warns when the lerd binary can't be located (#430). Under launchd the tray runs with an empty PATH, so
lerdBinwalks a fixed list of candidate install dirs before falling back to the bare string "lerd". That fallback used to be silent: menu clicks would fail with an exec error that surfaced nowhere obvious, leaving the user staring at an unresponsive tray. The function now prints a deduped warning to stderr the first time it falls through to the bare-name fallback, so launchd's error log surfaces the missing binary instead. Async.Oncekeeps the warning from spamming on every menu click. - TUI update-check banner stops false-firing on stable installs (#416).
tui.Runwas receivingversion.String()(e.g. "1.22.0 (commit ABCDEF, built 2026-05-25T15:43:56Z)") and threading it through toCachedUpdateCheck.splitPrereleasefinds the first-in the string and treats everything after it as a pre-release suffix, so the comparison degraded to "latest (no pre) > current (has pre)" and the banner fired even when the user was on the latest stable. The TUI now passesversion.Versioninstead, matching every other caller and keeping the header line short.
[1.22.0] — 2026-05-25
The headline is a profiler: SPX is bundled into every PHP-FPM image behind a single global toggle, and while it is on every request to every shared-FPM site is recorded as a flame graph, viewable in a same-origin Profiler overlay in the dashboard or driven from lerd profile and two MCP tools. The dashboard's System health card turns interactive, with the profiler, the dump bridge and notifications all becoming live antenna-style toggles backed by a reusable PulseToggle component. lerd init now seeds its setup wizard from an existing herd.yml, .ddev/config.yaml or .lando.yml so teams migrating from Herd, DDEV or Lando start from real values instead of a blank form. A new postgres-pgvector preset lands as a first-class service for vector-embedding workloads, alongside a reworked database picker that builds its option list dynamically so every installed family alternate shows up by name. And DNS gains a real handle on VPN clients: health goes three-way so a VPN rewriting the system resolver reads as a yellow degraded state rather than a red outage, lerd-watcher and lerd-ui both subscribe to kernel rtnetlink events so a connect or disconnect is reflected within a second in the dashboard pill and the new DNS entries in the Recent Activity feed, and container DNS is re-synced automatically against the new host resolvers so PHP can reach VPN-internal endpoints without a manual restart.
Added
- DNS state changes appear in the dashboard's Recent Activity feed (#410). Transitions surface as DNS degraded (amber, with a "VPN active" suffix when a tunnel is up), DNS down (red), and DNS recovered (green), so users get a historical record of when DNS state shifted, not only a live pill colour. Diff logic is a pure helper that emits exactly one event per status change and stays silent on the vpn-only flag flipping under a steady degraded state. Labels are translated across all eight existing locales.
- postgres-pgvector preset and a dynamic database picker (#392). The postgres-pgvector preset (v18 canonical, plus v17 and v16 alternates, image
pgvector/pgvector:pg<N>, host ports 5518/5517/5516) lands as a first-class lerd service for vector-embedding workloads instead of a hand-rolled custom yaml, and participates in pgadmin auto-discovery viadiscover_family:postgres. Wiring it up fixed three rough edges in the postgres-family code paths. Family resolution is now a single helper:config.FamilyOfandconfig.FamilyOfNameprefer the explicitFamilyfield over name-pattern inference, which previously only matched the<family>-<digit>shape and so resolved postgres-pgvector to no family at all, with the result thatenv.godecided it was not a database and skipped theDB_DATABASErewrite and database creation.config.IsDBServiceNameis the same idea for the boolean used bydb_setvalidation. The env wizard and thedb_setMCP tool now build their option list dynamically from the installed mysql and postgres family members plus sqlite, so any alternate (mariadb, postgres-pgvector, future ones) shows up by name instead of a hardcoded three-way choice. The multi-version preset picker gains canonical versions, marked(default)in the label and preselected, via a newconfig.PresetVersionServiceNamehelper that centralises canonical-versus-suffixed name resolution across the CLI table, the web UI, and the MCP list. Refs #378. lerd initseeds the setup wizard from Herd, DDEV and Lando config (#393). Teams migrating to lerd from Laravel Herd, DDEV or Lando already have a project config file that lerd previously ignored. Whenlerd initruns in a project that still carries aherd.yml, a.ddev/config.yamlor a.lando.yml, with both filename spellings recognised for each tool, it detects the file and asks whether to use it for the wizard defaults. Accepting it translates the PHP version, Node version, HTTPS preference, document root, domains and the database or service list into the wizard's defaults, which are then reviewed and confirmed exactly like any other run; declining leaves the wizard on plain auto-detection. The prompt only fires when no.lerd.yamlexists yet, so it never touches an existing lerd configuration. The translation is deliberately partial and everything it drops or changes is printed as a note before the wizard opens: pinned service versions and ports are dropped because lerd resolves those per machine, MariaDB folds into MySQL with a note pointing atlerd service preset mariadbfor anyone who needs it specifically, the framework is never translated since lerd auto-detects it more reliably, and seeded domains and document root are surfaced in the notes because the wizard has no prompt for them.- SPX profiler with a global toggle and dashboard view (#395). lerd gains a profiler for the question dumps and logs cannot answer, where a request actually spends its time. SPX, a low-overhead PHP profiler, is bundled into every PHP-FPM image behind a single global on/off switch; while it is on, every HTTP request to every shared PHP-FPM site is recorded as a flame graph. Turning it on or off flips a config flag, regenerates the FPM site vhosts so nginx injects an SPX cookie into each request, and reloads nginx, with no FPM restart and nothing to add to projects. The SPX cookie is suppressed whenever an
X-Forwarded-Hostheader is present, so the profiler stays unreachable through tunnels and LAN shares. The SPX report UI is reverse-proxied same-origin under/_spx/bylerd-uiand appears as a Profiler entry in the dashboard icon rail, opening in the same overlay the service dashboards use. From the terminal there islerd profile on/off/status/open,lerd profile runto profile a one-off artisan or CLI command, and theprofiler_toggleandprofiler_statusMCP tools. Profiling covers shared PHP-FPM sites only, since FrankenPHP and custom-container sites run images without the SPX extension. - Profiler, dump bridge and notification toggles in the dashboard System health card (#396). The System health card gains interactive toggles: the SPX profiler gets one, the dump bridge row becomes a real toggle instead of a read-only dot, and the notifications row is a bell toggle that flips the master switch and prompts for browser permission on first use, dimming when the browser has blocked notifications. Each is an antenna-style emerald icon with a slow pulsing dot while the feature is live, and all six System health indicators now sit in one centered column so the card reads as a single set. The left rail's profiler icon carries a green dot whenever profiling is on. The Profiler overlay gains a Clear data button, with clearing captured reports also available as
lerd profile clear, theprofiler_clearMCP tool, and the/api/profiler/clearendpoint, all backed by a sharedprofiler.ClearData. Profiler state is broadcast over the websocket as a newprofiler_statuskind so the indicator and the rail dot stay live when the profiler is toggled from the CLI, MCP, or another browser tab, matching how the dump bridge already behaved. The shared antenna-style button was extracted into a reusablePulseTogglecomponent.
Fixed
- VPN connect and disconnect react within a second via rtnetlink (#410). The beta's container DNS re-sync and the dashboard pill were both poll-driven on a 30 second interval, so after a VPN toggle the pill could sit on a stale red colour and PHP inside containers could keep resolving against the wrong forwarders for up to a minute. lerd-watcher and lerd-ui now both subscribe to rtnetlink
RTMGRP_LINK/RTMGRP_IPV4_IFADDR/RTMGRP_IPV6_IFADDRmulticast events with a 750ms debounce, so a kernel-reported link or address change kicks an immediate re-probe and re-sync. The 30 second poll stays as a safety net so a missed kernel event cannot strand the system. VPN detection is also broadened: ProtonVPN'sproton0andprotonwire0, Mullvad, and any branded client whose interface carries the POINTOPOINT flag now register as a VPN, so the degraded-state downgrade fires for them too. The same fallback engages for cellular tethering, where reading a broken system resolver as degraded rather than down is the desired outcome. On hosts where rtnetlink is unavailable (seccomp-filtered services, certain hardened container namespaces),LinkChangesemits a one-shot warning to journalctl and the existing poll takes over. macOS keeps the build-tagged no-op path: both watchers fall back to the 30 second poll, unchanged from before, since macOS containers resolve through the podman machine VM. - DNS no longer false-alarms under a VPN, and container DNS re-syncs automatically (#397). Connecting a corporate VPN such as Cisco AnyConnect made the dashboard show a red DNS outage even though lerd-dns was healthy and answering, because the VPN client rewrites the system resolver so the TLD stops routing to lerd-dns. DNS health is now three-way: when the system resolver path fails, lerd queries dnsmasq directly on
127.0.0.1:5300, and a direct answer reads as a yellow degraded state rather than a red down. The degraded state renders consistently across the dashboard pill, the tray icon, the TUI header andlerd status,lerd doctordowngrades the system lookup rung to a warning when a VPN tunnel is up, and the UI status watcher debounces transitions across two ticks so a momentary blip while a VPN connects cannot latch the pill. Separately, PHP inside the containers could not reach VPN-internal API endpoints until lerd was restarted, because the lerd network's aardvark-dns was left on the pre-VPN forwarders and a stale cache. The watcher now fingerprints the host resolver environment every tick and, when it changes, re-syncs the container network DNS automatically, re-pointing aardvark-dns at the current resolvers and reloading the network so containers pick them up with a fresh cache. The re-sync runs on Linux only, since macOS containers resolve through the podman machine VM. Reported in #394.
[1.21.2] — 2026-05-22
A hotfix for three issues a user hit while migrating an Ubuntu development stack to Lerd: a spurious MySQL readiness warning printed in front of every PHP command, a fatal parse error from the dump bridge on legacy PHP, and a failed PHP 7.2 image build.
Fixed
could not start mysqlwarning on every PHP command even though MySQL was healthy. The readiness probe ranmysqladmin pingwith no host, so the client fell back to the default Unix socket at/var/run/mysqld/mysqld.sock. MySQL images that keep their socket elsewhere, and MariaDB which uses a different path again, failed the probe for the full 30 second timeout while the server was up and serving connections, leaving amysql is active but not yet readywarning in front of every command. The probe now connects over IPv4 loopback TCP, which is independent of the image's socket path and, because it targets the container's own loopback, stays correct on macOS and on IPv6-only host networks. The MySQL service-migration probe, dump, and restore commands force TCP the same way solerd service migrateis consistent with the Postgres path.- Dump bridge crashed legacy PHP with a fatal parse error. The
dump()/dd()bridge that Lerd installs as anauto_prepend_filewas written withmatch,mixedandnevertype declarations,str_contains/str_ends_with, andarray_key_first, none of which exist on PHP 7.4 (and, for several of them, 8.0). On those versions PHP aborted withsyntax error, unexpected '=>'before running any code, breaking every CLI command and web request until the mounted ini was removed by hand. The bridge is rewritten in syntax that parses and runs on every PHP version Lerd builds, down to the 7.2 floor, with identical behaviour on current PHP. lerd php:rebuild 7.2failed to build the image. PHP 7.2's Alpine 3.12 base predates several things the Containerfile assumed: theicu-data-fullpackage, gd's post-7.4 configure flags, current phpredis and xdebug, thebatpackage, and a transitivelibgompthat the ImageMagick extension needs at runtime.icu-data-fullnow installs tolerantly because older Alpine folds the full ICU locale data intoicu-libs, gd's configure branches onPHP_VERSION_ID, xdebug and phpredis are pinned to the last releases supporting the older PHP line,batinstalls tolerantly, andlibgompis added explicitly so ImageMagick loads. PHP 7.2 stays an unshipped legacy tier with no pre-built base image, but a locallerd php:rebuild 7.2now produces a working image.
[1.21.1] — 2026-05-19
A targeted fix for npm install -g and the same long-standing issue for composer global require. npm globals went into a fnm version-specific bin directory that nothing has on PATH, so npm install -g pm2 succeeded but the resulting pm2 was unreachable from the user's shell. Composer globals had the same shape, landing in ~/.config/composer/vendor/bin/ which is also not guaranteed on PATH (especially on macOS Homebrew installs), so composer global require psy/psysh left psysh callable only via its full path. lerd now points npm's prefix at a stable per-user path under ~/.local/share/lerd/node-global/, mirrors composer's existing global vendor/bin, and writes small wrapper scripts for every global binary from either source into ~/.local/share/lerd/bin/, the directory lerd install already adds to PATH on both Linux and macOS regardless of whether lerd itself came from Homebrew or a curl-pipe install. Node wrappers exec the real bin through fnm exec --using=default, composer wrappers exec through lerd php, so #!/usr/bin/env node and #!/usr/bin/env php shebangs both resolve against lerd's managed runtimes from any directory. npm uninstall -g and composer global remove clean their wrappers alongside the package. Each category uses its own marker so the two syncs coexist without removing each other's wrappers, and files in the bin dir without any lerd marker (existing node, npm, npx, php, composer, laravel, fnm, mkcert shims, plus anything the user dropped in by hand) are never touched. The node, npm, npx, and composer shell shims route through the lerd binary first so the new behaviour applies to bare npm install -g and composer global require, not only to lerd npm and lerd composer, falling back to a direct fnm or composer.phar invocation in containers where the glibc lerd binary cannot exec.
Fixed
- Globally installed npm packages were orphaned in fnm's version directory (#390).
npm install -g pm2finished cleanly butpm2was not callable because the binary landed under~/.local/share/fnm/node-versions/<v>/installation/bin/, which is not on the host PATH. lerd now setsnpm_config_prefix=~/.local/share/lerd/node-globalfor everynpmandnpxinvocation routed through its shim, and after the command exits it mirrors every executable in that prefix'sbin/to a wrapper script in~/.local/share/lerd/bin/. That directory is the onelerd installadds to PATH via the user's shell rc on every supported platform, so the wrappers are reachable from a fresh shell whether lerd itself was installed via Homebrew on macOS, curl-pipe on Linux or macOS, or a packaged distro build. The wrapper exec's throughfnm exec --using=defaultso the#!/usr/bin/env nodeshebang resolves under the fnm-managed default node regardless of cwd, project-pinned versions, or whether the tool is invoked from outside any project. Uninstalls remove the wrapper too. Files in~/.local/share/lerd/bin/without lerd's marker comment are left alone, so the existingnode,npm,npx,php,composer, andlaravelshims that share the directory are never clobbered, and the head-only marker check rejects native binaries (which can carry the marker bytes as a string constant) so the cleanup pass cannot accidentally removelerditself or any other Go binary that happens to live nearby. - Bare
npm install -gdid not route through the new prefix logic (#390). Thenode,npm, andnpxshims in~/.local/share/lerd/bin/previously calledfnm exec -- <bin>directly, so the prefix override only applied when the user explicitly typedlerd npm. The shims now delegate to the lerd binary first viaexec "$LERD" <bin> "$@"whenever it is reachable, and fall back to the original direct-fnm path inside containers where the glibc lerd binary cannot exec. The friendly "No Node.js version available via lerd. Run: lerd node:install 22" hint surfaces through both paths. composer global requireleft binaries unreachable from PATH (#390). The composer flow had the same shape as the npm one:composer global require psy/psyshsucceeded but the resultingpsyshlived under~/.config/composer/vendor/bin/, which is not guaranteed on PATH (worst on macOS Homebrew). A newlerd composersubcommand now wraps the existinglerd php composer.pharflow and, after composer exits, syncs$COMPOSER_HOME/vendor/bin/to~/.local/share/lerd/bin/as small wrapper scripts that exec the real bin throughlerd phpso the#!/usr/bin/env phpshebang always resolves against the FPM container's PHP. Composer wrappers carry their own marker so they coexist with npm wrappers in the same directory without either sync removing the other's files, and the existing hand-writtenlaravelshim (which has no marker) is preserved as a foreign file so users who relied on the install-time shortcut keep working. The shellcomposershim delegates tolerd composerfirst when the lerd binary is reachable, falling back to the originallerd php composer.pharpath otherwise.RunPHPgrows a non-exiting sisterRunPHPCaptureso the sync can run on every exit path, including failedcomposer global removeruns that leave the bin dir half-cleaned.
[1.21.0] — 2026-05-19
The 1.21.0 line ships desktop notifications: a single in-process notifier inside lerd-ui fans every event out across the dashboard WebSocket and RFC 8291 Web Push, so captured mail, worker failures, finished service operations, registry update transitions, and ray/dump arrivals reach you even when no tab is open. The PHP-FPM image grows a real shell environment (zsh + starship + a handful of modern CLI tools, all isolated from the host) and then loses about 800 MB of build toolchain in a multi-stage split, dropping localhost/lerd-php85-fpm:local from 1.36 GB to 535 MB without losing any of the 68 PHP modules it ships. A new on-demand commands feature surfaces one-shot framework actions (cache clears, migrations, login URLs, anything you put in yaml) across the dashboard, the lerd run CLI, the command palette, and four new MCP tools, all backed by a generalised Dropdown component that replaces every native select in the UI. The site detail header gets a browser-style address bar with the favicon, TLS lock, LAN-share chip, and worktrees promoted from a dropdown to tabs, an Env tab joins Overview/Tinker/Dumps to show the project .env verbatim, and tray menu picks up Dump bridge and Notifications toggles that update live via a new KindDumpsStatus event. Postgres grows 17 and 18 alternates alongside a new MySQL 9.7 LTS line, all gated by a canonical-version pin so flipping the yaml's canonical no longer silently major-jumps existing installs. Plus Türkçe (tr) joins the dashboard languages, a public_dir override lands in .lerd.yaml for projects with a non-standard document root, every git invocation in the tree now flows through internal/git, and worker-failure pushes are batched so a systemd cascade no longer fires six near-identical notifications back to back.
The path to stable then folded in three transitive dependency bumps closing five Dependabot alerts (jwt-go to 5.2.2 for the header-parsing memory-allocation flaw, svelte to 5.55.8 for the DOM-clobbering and SSR-spread XSS paths plus hydratable promise serialization XSS, kysely to 0.28.17 for the JSON-path traversal injection), a LAN-exposure audit that closes three dashboard endpoints that were reachable on lan:expose installs (raw .env, push-test, and an unauthenticated mailpit webhook) and adds path-traversal validation for the new public_dir override, a catatonit PID 1 swap for mysql and mariadb so podman stop returns in a second instead of timing out at 30s, a host-worker stop-stays-stopped fix that also threads lerd's bin directory onto PATH for npm-spawned subprocesses so wayfinder and friends can find php, the restoration of git in the PHP-FPM runtime stage that the multi-stage split dropped, notification clicks that finally land on the right tab, and a container.target field in .lerd.yaml that lets multi-stage Containerfiles pick a development or production stage instead of always building the trailing one.
Added
- Desktop notifications via Web Push with per-category settings (#353). Five notification kinds ride a single in-process notifier inside
lerd-uithat fans every event out across/api/ws(open tabs) and Web Push (closed PWA / minimised tabs): captured mail from Mailpit, queue/horizon/reverb worker failures, finished service operations (install, migrate, reinstall, update, rollback) with success/failed variants, registry update-available transitions, andray()/dump()arrivals with the dumped content as a single-line preview in the body. Push is RFC 8291-encrypted, signed with a per-install VAPID key pair stored under~/.local/share/lerd, fans out from the centraldispatchNotificationchokepoint, and prunes subscriptions automatically on 410/404. The service-worker push handler is kind-agnostic, reading title, body, tag, url, and data straight off the payload so the SW never grows kind-specific branches. Settings live under System → Desktop notifications: master switch plus per-category toggles, a test-push button, and a subscribed-devices list with Forget actions. Per-kind preferences are stored both client-side in localStorage and server-side on the Subscription so closed-PWA Web Push honours the toggles too. Every visible string is translatable via Paraglide keys undernotify_*, with the English fallback always travelling in the payload so the SW (no DOM, no Paraglide) keeps rendering correctly. Worker-failure detection no longer gates on whether a dashboard tab is visible; only the eventbus publish does, so a closed-PWA developer still gets the push when a queue worker dies. - Notifications page polish and dashboard health row (#354). Denied state now expands into a banner with the page origin, per-browser unblock instructions picked from
navigator.userAgent(Chromium, Firefox, Safari, generic), and a recheck button so users who fix the permission in browser settings don't have to hard-reload to see the page transition out of denied. The panel follows theDetailPanel/DetailHeadershape used by DNS, Nginx, Watcher, and Dump-bridge, with a single Enable/Disable button in the header trailing slot next to aStatusPilland category-toggle rows that stretch to the full panel width. The sidebar dot, the System Health widget row on the dashboard, and the header pill now share onenotifyEffectiveOnderivation (permission granted, not auto-subscribe-disabled, master on) so the three indicators agree in every state. The label across the UI is now just "Notifications". - Turkish (
tr) locale for the dashboard (#355). Türkçe joins English, German, Spanish, French, Indonesian, Dutch, and Portuguese with 620 strings translated to full coverage. Glossary picked for technical accuracy and Türkçe convention: Dashboard → Pano, Service → Servis; Worker, Worktree, Horizon, Reverb, Stripe, FrankenPHP, and Tinker stay as-is.LOCALE_LABELS["tr"] = "Türkçe",LOCALE_CODES["tr"] = "TR", the inlang settings list, and the key-drift test all pick up the new locale. - Zsh + starship baked into the PHP-FPM image for
lerd shell(#358).lerd shelland the TUI shell action previously dropped into baresh, which made the in-container experience feel like a different planet from the host. The PHP-FPM image now ships zsh with a self-contained config (starship prompt,compinit -u, persistent history under~/.local/share/lerd/shell-state/php-<ver>/zsh/) and a handful of modern CLI tools (eza, bat, fzf, zoxide). The quadlet setsHostName=to the host's hostname so the prompt readsroot@your-machineinstead of an opaque container id, and the renderer mounts the per-version zsh history directory read-write so commands survive container rebuilds.InteractiveShellScript()picks zsh in the FPM image and falls back through bash to sh for other containers. The shell environment is deliberately isolated from the host's~/.zshrcand~/.config/fish: an earlier draft mounted those, but every customised host config introduced its own cascade of "no such file or directory" errors as it tried to source distro-specific paths the alpine container had never heard of. The container shell is its own world now, consistent across every machine and contributor. - Postgres 17 and 18 alternates, MySQL 9.7 LTS, canonical version pinning across multi-version presets (#361). Postgres is restructured into a multi-version preset with 16 canonical plus 17 and 18 alternates, MySQL gains the new 9.7 LTS line as an alternate, and the obsolete MySQL 8.0 alternate is removed because 8.4 LTS already covers that line. Multi-version presets now snapshot the canonical version tag at install time into
ServiceConfig.CanonicalVersion, so a future release flipping the yaml's canonical (e.g. postgres 16 to 18) will no longer silently major-jump existing installs. The pin survives reconciles, gets backfilled from the running image for pre-pin installs, and is updated bylerd service migratewhen the user moves the canonical on purpose. Postgres 18 neededPGDATApinned to the legacy/var/lib/postgresql/datapath because the upstream image moved its default into a version subdirectory and refuses to start otherwise; MySQL 9.x removedmysql_native_passwordentirely, which was named in the sharedlerd.cnf'sauthentication_policyline and aborted init, so that line is gone and the defaultcaching_sha2_passwordis used on every supported version. pgadmin now auto-discovers postgres family members the same way phpmyadmin discovers mysql and mariadb: adynamic_envdirective registers it as a family consumer, theservers.jsonandpgpassfile mounts are generated at materialise time fromServicesInFamily, andPGADMIN_REPLACE_SERVERS_ON_STARTUPmakes pgadmin re-import on every restart so installing a postgres alternate immediately shows up in the dashboard without a manual server-add step. To support that,FileMountgained an optionalContentFngenerator alongside the staticContentfield. Closes #360. - On-demand framework commands across UI, CLI, and MCP (#363). A new commands feature surfaces one-shot admin actions (cache clears, migrations, login URL generation, anything you put in yaml) for every registered site. The dashboard exposes a Commands dropdown in the site controls band, the CLI exposes
lerd run <name>, and the command palette aggregates commands across all sites with a single multi-term search. MCP gains four tools so AI assistants can list, run, add, and remove commands without hand-editing yaml. The Go side introduces aFrameworkCommandschema (yaml-defined or inline in a project's.lerd.yaml), aResolveCommandsmerge function that lets projects override or disable framework defaults by name, a streaming run endpoint that interleaves stdout and stderr over SSE with per-site mutual exclusion and loopback gating, and anoutput: terminalmode that spawns the user's terminal emulator instead of streaming.lerd checkvalidates the schema. Built-in Laravel and Symfony come with the same command set hardcoded so sites work before the framework store fetch lands. The frontend introduces a generalisedDropdowncomponent (object options, keyboard navigation with arrow keys and type-ahead, ARIA roles, mobile-aware popover sizing) that replaces every native select in the UI for visual consistency. The commands feature uses it for both the per-site Commands menu and the inline run modal, which renders streamed output with ANSI color support and distinguishes stderr from stdout. Run state is global so the palette and the dropdown share the same modal; finished runs are kept in localStorage so users can review previous output without re-running, and long runs that finish while the tab is hidden trigger a desktop notification when permitted. Docs land indocs/features/commands.mdand the MCP skill picks up updated tool descriptions. - Multi-stage PHP-FPM build cuts image size by ~60% (#364). The PHP-FPM image was carrying about 800 MB of build toolchain (autoconf, make, g++, linux-headers, and a long list of
-devheaders) that pecl anddocker-php-ext-installneed at compile time but nothing needs at runtime. Splitting the Containerfile into a builder stage that compiles every extension and a runtime stage that only ships musl runtime libs plus the compiled.sofiles bringslocalhost/lerd-php85-fpm:localfrom 1.36 GB to 535 MB on top of #358, with no functional regressions. A newbuildCustomExtRuntimeDepshelper emits a singleapk RUNline in the runtime stage that reinstalls the apk packages user-configured extensions need; without it, custom extensions like imap (which needs c-client at runtime, not just at build time) would compile in the builder fine but fail to dlopen at runtime. The well-known imap case verified the round-trip works.baseContainerfileHashstrips the new{{.CustomExtensionsRuntime}}placeholder so pre-built base image cache keys stay stable. Every PHP extension (68 modules including imagick, redis, mongodb, igbinary, pcov, xdebug, opcache, all base extensions), composer, node + npm, ghostscript, imagemagick, ffmpeg, mysql-client, and the zsh/starship/etc shell environment from #358 are preserved. The base-images workflow now strips the new placeholder when hashing the Containerfile, and after each per-arch push a smoke step pulls the just-built amd64 image, counts loaded PHP modules, fails if the count drops below 50, and verifies a handful of load-bearing extensions (curl, gd, intl, mbstring, pdo_mysql, redis, xdebug, zip) are present plus that PDO has its three drivers and composer runs, so silent extension drops get caught at publish time instead of on user rebuilds 24 hours later. - Browser-style address bar header for the site detail panel (#365). The site detail panel now renders its top region like browser chrome. The dominant element is a pill-shaped address bar with the favicon, scheme, and domain, flanked by a circular reload, pause/resume, open-in-browser, a LAN-share toggle, terminal (loopback only), and unlink. The TLS lock at the leading edge of the pill clicks through to flip HTTPS, with the gating logic from the old
SiteControlstoggle ported over so it stays static when DNS is off. Clicking the URL itself opens the Manage Domains modal, leaving the explicit Open button as the way to launch the site in a browser. When LAN sharing is on, the existing teal URL chip with its hover-QR slots into the bar next to the framework badge instead of living below the controls row. Worktrees are tabs above the bar rather than a dropdown: the first tab is the main checkout with a home glyph, every other tab carries the branch icon and an inline X. The X opens a newRemoveWorktreeModalscoped to that branch, and the trailing + opensAddWorktreeModal. The old combinedWorktreeModalis gone because the tab strip already shows every worktree and removal is one click away. Side effect:openSiteInBrowserno longer ignores TLS on worktrees, so worktree subdomains correctly open as HTTPS when the parent site has TLS enabled, matching what nginx and the cert manager have always done. - Env tab on the site detail panel (#366). A new Env tab joins Overview, Tinker, and Dumps that renders the site's project
.envfile as raw, selectable, copyable text. The file shows verbatim, comments, blank lines, and ordering preserved, with a one-click copy button mirroring the existing services-sideEnvBlock. The tab only appears when the site actually has a.envfile on disk, so non-Laravel or static sites that never had one stay clean. A newhas_envfield on the sites snapshot makes that gate cheap on the frontend, and a newGET /api/sites/{domain}/envendpoint serves the raw bytes, accepting the same?branch=parameter the rest of the site routes use so the tab follows the active worktree selector just like Tinker does.EnvBlocknow accepts either a parsed vars map (existing service callers) or a raw text prop (the new site tab), keeping the styling and copy logic in one place. public_diroverride in.lerd.yaml(#370). Projects with a non-standard document root (e.g. a Laravel skeleton usingpublic_html/instead ofpublic/) can now declare it directly in.lerd.yamlwithout having to clone the entire framework definition. TheSitestruct already had aPublicDirfield andresolvePublicDir()already preferred it over the framework default; what was missing was a route to populate it from the per-project file.ProjectConfiggains apublic_dirfield,lerd linkthreads it into the site, andlerd initpreserves it on re-runs. The existing nginx vhost templates already use the resolved value, so no template churn was needed. Also promotes the Configuration page from/reference/configurationto a top-level/configurationentry in the docs nav and documents the new field with a short Custom public folder example. Closes #369.- Dump bridge and Notifications toggles in the tray menu, live dump-status updates on the dashboard (#373). The tray picks up two new items, Dump bridge and Notifications, alongside a global
lerd notify on/off/statuscommand. Notifications gain a config-level mute vianotifications.disabledinconfig.yaml, which the centraldispatchNotificationchokepoint now consults so neither the WebSocket banner broadcast nor the Web Push fanout runs while the gate is closed. Per-device dashboard prefs still apply on top when the gate is open. The CLI subcommand and the tray click both flow through the same path so the surface stays consistent. The dump-bridge indicator on the dashboard now updates live:dumpsops.Applystill owns state, butlerd-uilearns about it three ways. The dashboard's/api/dumps/toggleand/api/dumps/passthroughare wrapped withpublishAfterso cross-tab updates fan out, a new loopback-only/api/dumps/notify-changedendpoint lets the CLI nudge the daemon afterlerd dump on/off, and a newKindDumpsStatuseventbus kind broadcastsbuildDumpsStatusJSONover the WS. The frontend's dumps store subscribes to that frame and writes incoming payloads straight into the existing status writable, so the antenna dot, the system sidebar indicator, and the dashboard health widget all flip without a refresh. Tray clicks stop waiting for the next 30s poll too: every toggle handler shares a single refresh callback that fetches a fresh snapshot the moment the underlyinglerdcommand exits, so the menu label reflects the post-command state right after the click instead of lagging by up to half a minute. container.targetfield in.lerd.yamlfor multi-stage Containerfile builds (#385). Projects whose Containerfile defines separate development and production stages can now declarecontainer.target: developmentin.lerd.yamland lerd threads--target <name>into both the synchronouslerd linkbuild and the streaming UI rebuild. A sharedbuildCustomImageArgshelper keeps the two code paths from drifting, and the cache key inhashContainerfilemixes the target value in with a NUL delimiter so flipping between stages on an unchanged Containerfile actually rebuilds instead of serving the previously-built stage. Docs and the MCP skill table pick up the new field. macOS needs no extra wiring since the flag rides on the standardpodman buildargv that already crosses into the podman-machine VM. Addresses #379.- MCP
service_addaccepts theinitflag (#386). Theinitfield landed onCustomServiceand the preset schema in #383 so the quadlet generator can wire podman's--initwhen an image's main process ignores SIGTERM as PID 1, but the MCPservice_addtool never picked up the corresponding argument. An agent registering a custom service through the wire couldn't turn the flag on without editing the YAML by hand. The tool's input schema now advertisesinitalongside the otherCustomServicefields, so MCP-driven workflows reach feature parity with the.lerd.yamlpath.
Changed
- Every git invocation in the tree now flows through
internal/git(#356). Seven production call sites outsideinternal/git/were spawning git viaexec.Commanddirectly, each with its own stdout/stderr wiring. They now route through four new helpers ininternal/git/exec.goso the package owns every git invocation in the codebase. The new surface:Output(dir, args...) (string, error)captures stdout,Run(dir, log, args...)sends both stdout and stderr to a single writer (the modal-log pattern),RunTTY(dir, args...)wires stdout/stderr straight to the user's terminal so shell redirects still work,RunCaptureStderr(dir, args...)mirrors stdout to the terminal while teeing stderr into a buffer the caller inspects for git's "use --force" hint, andBranchExists(dir, branch)wraps theshow-ref --verifypattern. Routed sites:internal/cli/worktree_ui.go(branch-exists, worktree add, worktree remove),internal/cli/worktree_add.go,internal/cli/worktree_remove.go(worktree remove with force-detection plus the genericrunGit), andinternal/ui/server.go(runGitOutputwrapsOutput). Each preserves the originalcmd.Dir, stream wiring, and exit-code semantics. The only behaviour delta is the wrap text on errors, and no caller pattern-matches on that text. - Batched worker-failure notifications (#372). A systemd cascade like start-limit-hit hitting half a dozen queue workers at once, or an OOM kill taking out every worker for a site in the same tick, used to fire one push per unit and spam the user with five or six near-identical "Worker failed on …" notifications back to back. The watcher detects new failures every five seconds, so the bursts were tightly clustered and unavoidable on the wearer side. The watcher now hands its new-failure delta to a small batcher that buffers by unit and arms a single five-second flush timer. Failures arriving later in the same window join the in-flight batch without resetting the timer, so the grouped push lands at most five seconds after the first failure even under a sustained cascade. A single isolated failure still goes out with the existing per-unit shape (deep link to the affected site, per-unit tag for browser dedupe), while two or more collapse into one "N workers failed" payload listing every
worker@siteand taggedlerd-workers-groupso a later grouped push supersedes the earlier one in the notification tray instead of stacking. Matchingnotify_worker_failed_group_titleand_bodykeys are added to all eight locale message files, with the Turkish strings translated and the rest falling back to English the same way the existingworker_failedentries do.
Fixed
- Worktree-manager button rendered on non-git sites (#357). The gear icon next to the worktree picker in the site header opens the worktree-management modal, but on a site without a git repo there's nothing for that modal to manage. The button gated only on
site.paused, so it appeared anyway on non-git linked projects. TheWorktreePickeron the same row already gates its passivegit:(branch)label onsite.branchbeing set; the button now reuses the same condition so the two affordances behave consistently. No git, no worktree UI. - Adding or removing a secondary domain on a secured site left the TLS cert untouched (#367). The five call sites that mutate a site's domain list (the CLI
domain addandremovesubcommands, the HTTPdomain:add/domain:edit/domain:removehandlers, and the MCP equivalents) were all callingcerts.IssueCert, which is a documented no-op when the cert file already exists. Since the primary domain's cert always exists for a secured site, the new SAN list was silently discarded and browsers kept rejecting the new hostname with "connection is not private" until the cert was nuked by hand. Every domain-mutating caller now goes throughcerts.ReissueCertForWorktree, which enumerates the site's worktrees viagitpkg.DetectWorktrees, combines them withsite.Domains, and callsIssueCertForceso the cert atomically picks up the new SAN list while keeping existing worktree subdomains in scope. A focused unit test in the certs package and two handler-level tests in the ui package pin both regression vectors so neither this bug nor the worktree-subdomain regression that an earlier draft of this fix introduced can come back. - Streamed worktree install and post-v1.20.2 audit follow-ups (#368). Composer and npm output during
lerd worktree addnow streams to the UI modal via a cross-process flock (internal/git/install_lock.go); the watcher's auto-install skips when the UI holds the lock, and a 60-second periodic rescan recovers any worktree the UI left un-provisioned.WorktreeCheckoutPathnow nests under the parent site (<site>/<base>-<slug>) and writes a/<base>-*/entry to.git/info/exclude. Other fixes in the same pass: the UI dropdown's scrollbar is visible again (droppedno-scrollbar) and wheel scrolling inside the menu no longer closes it, same fix toCommandsDropdown; the UI commands runner prependsBinDirtoPATHso php/composer/npm shims resolve under launchd's restrictedPATHon macOS, refuses to run when?branch=doesn't resolve to a real worktree, and defer-recover wraps the SSE pipe goroutines; macOSlaunchd_darwin.containerToPodmanArgsnow emits--hostnamesolerd shellshowsroot@<host>;service migratesyncsCanonicalVersioncorrectly for postgres/postgis-style image tags (18-3.6-alpine) viamatchVersionByImageTag; the worker health watcher now seeds health state before the tick loop so a clean start followed by a new failure dispatches (the previous fix silently swallowed the first failure on clean start); push gains a success log line andendpointShortusesurl.Parse;notify.tsswitches from a singlelastTagstring to per-kind plus 2s-window dedupe; plus a UTF-8-safedumpPreview, logged parse errors onnotifyOnServiceUpdates, base-images smoke that runs on arm64, and a platform-aware shell hint. - Tinker swallowed bare-expression results when the dump bridge was enabled (#371). When the dump bridge is on, its custom
dump()ships the value to the dashboard socket but stays silent on stdout unlessdumps.passthroughis also on. Tinker auto-wraps bare expressions likeUser::count()indump(), so the bridge was silently swallowing the result and the REPL returned an empty block unless the user reached forecho. SettingLERD_DUMP_PASSTHROUGH=1on every tinkerpodman execkeeps the dashboard capture intact while letting Symfony VarDumper also print to stdout, restoring the value in the tinker output regardless of the global passthrough setting. - Stopped host workers resurrected on the next fsnotify event or launchd heal tick (#375, #376). A host worker that the user stopped via the UI or
lerd worker stopwas getting silently revived. Two paths were doing it: every HEAD write inside a worktree (commit, checkout, rebase, branch rename) fired the fsnotify "changed" handler, which was unconditionally callingAutoStartOptedInWorktreeWorkersand re-bootstrapping every opted-in host worker; and on macOS the launchd heal loop additionally treated a missing plist as drift and recreated it, even thoughWorkerStopForSiteremoves the plist precisely to represent user intent. A newshouldAutoStartWorkersOnSynchelper gates the change handler toaction == "added"so HEAD movement leaves existing units alone, andshouldHealOnReasonskips"plist missing"and only heals genuine drift like"not loaded in launchd"or"loaded but no live process". The same report also flagged that wayfinder'sphp artisan wayfinder:generate --with-formfailed insidenpm run devwith/bin/sh: php: command not foundbecause the launchd guard script and the systemd unit for host workers never put lerd's bin directory on PATH; both paths now prependconfig.BinDir(), and the Linux unit re-adds~/.local/binso systemd-user's inherited search path doesn't quietly narrow. Closes #381. Also localizes four hardcoded English literals in the new address-bar header from #365 (TLS toggle, LAN toggle, mobile-overflow LAN entry, overflow menu aria-label) so non-English users (Turkish shipped in #355) stop seeing English on the most-touched UI of v1.21.0. - VCS-typed composer repositories broke after the multi-stage split (#377). The PHP-FPM rebuild in #364 cut image size by moving git into the builder stage, where it was only needed for the phpredis/imagick fallback clones. The runtime stage lost it without anyone noticing. Composer falls back to dist for most flows, but
"type": "vcs"repositories declared incomposer.jsonhard-require a git clone, and any plugin that shells out to git for tag detection or repo state failed the same way. Re-adding git to the runtimeapk addbrings the previous behaviour back at the cost of a few megabytes, and a smoke test on the embedded Containerfile catches a future regression at publish time. - Three dashboard endpoints were reachable from the LAN when
lan:exposewas on, andpublic_diraccepted path traversal (#382).GET /api/sites/{domain}/envhanded out the raw.env(APP_KEY, DB credentials, third-party tokens) once the user enabledlan:expose,POST /api/push/testlet a LAN attacker spam pushes onto subscribed devices, andPOST /api/webhooks/mailpitwas bypassing the remote-control gate unconditionally so any reachable host could fire fake mail notifications with attacker-controlledsubjectandfrom. The first two now ride the existingloopbackOnlyRoutesandloopbackOnlySiteSubactionslists inwithRemoteControlGateso they 403 from non-loopback callers regardless of LAN or remote-control config. The mailpit bypass keeps the pre-auth path mailpit needs but tightens the check tofromHost(r), which verifies the source IP belongs to one of the host's interfaces; pasta on Linux and gvproxy/vmnet on macOS source-NAT the container to that address, while a LAN attacker arrives from elsewhere and a spoofed SYN breaks the TCP handshake because SYN-ACK routes back into the host.fromHoststrips IPv6 zone suffixes, parses sources withnet.ParseIP, and usesIP.Equalso v4, v6, zoned link-local, and v4-mapped-v6 sources all compare correctly. Separately, thepublic_diroverride added in #370 was concatenated into the nginx vhost'srootdirective without validation, so cloning a hostile repo into a parked directory could pivot the document root via../../etc. A newValidatePublicDirrejects absolute paths, leading~, NUL bytes, and any..segment;LoadProjectConfigsilently falls back to the framework default with a warn when the value is bad, andresolvePublicDirdouble-checks at render time as defence in depth. - mysql and mariadb
podman stophung the full grace window (#383).mysqldin mysql 8.4 (and mariadb, which shares the same daemon entrypoint shape) does not install a SIGTERM handler when it runs as PID 1, sopodman stopwaited the full 30s before SIGKILL andsystemctl restartwedged around the 30-90s mark. A newinitflag onCustomServiceand the preset schema threads through toPodmanArgs=--initin the quadlet generator, and the mysql and mariadb presets turn it on. Podman injects catatonit as PID 1, mysqld lands at PID 2 with its normal signal-handling thread intact,podman stopcompletes in ~1s, andlerd service restartreturns instead of timing out. The flag is opt-in per preset since postgres, redis, and the rest of the bundled services already handle PID 1 signals correctly. Closes #380. - Notification clicks landed on a non-existent or wrong tab (#384). Two of the dashboard's push notifications carried URLs the router couldn't resolve: the dump notification used
#dumps(not a top-level tab, soparseHashsilently fell back to dashboard) and theworker_failednotification used#sites/<name>(Sites is keyed by primary domain, so on any site where name and domain differ —whitewaterswiththeregistry.test, for example — the click landed on an empty state). Both now route through a smallsiteDomainForRoutehelper that resolves a registered site name to its primary domain at notification build time, falling back to the input verbatim when no site matches so test fixtures and unlink races stay safe. The dump notification points at#sites/<domain>/dumps, theworker_failedURL becomes#sites/<domain>, andSiteDetailpicks up an optional sub-tab segment fromrouteRestvia a$effectthat overrides the localStorage-restored tab when the segment names a known sub-tab.
Security
- Three transitive dependency bumps close five Dependabot alerts on main (#387).
github.com/golang-jwt/jwt/v5moves from 5.2.1 to 5.2.2 to close the header-parsing memory-allocation flaw (high). Svelte goes from 5.55.6 to 5.55.8, covering the three open medium-severity advisories: the DOM-clobbering XSS path, the SSR spread-attribute XSS, and the hydratable promise serialization XSS. Kysely steps to 0.28.17 to clear the JSON-path traversal injection (high), pulled in transitively by@inlang/paraglide-jsat build time. The Go-side JWT is an indirect dep with no direct call sites in lerd, so runtime exposure was small even before the bump. Svelte is the real one since the dashboard renders user-controlled site names, env values, and dump payloads, so each of those XSS paths was a live concern for any LAN-shared or remote-controlled install. Kysely is only reachable through paraglide's build-time SDK so runtime impact was nil, but the lockfile drift was noisy.
[1.20.2] — 2026-05-14
A lerd lan:share follow-up that fixes the bugs uncovered while sharing a real Laravel + Vite + RustFS site over the LAN. The tunnel share proxy was passing gzipped responses through untouched, so on most frameworks the page loaded but every asset URL still pointed at the local .test domain and the share rendered white. The LAN share proxy was setting X-Forwarded-Host but not X-Forwarded-Port, so Symfony's Request::getSchemeAndHttpHost() and Ziggy's URL helper composed asset URLs out of the proxy's host plus nginx's $server_port=443, leaking http://<lan-ip>:443/... into the page. Behind that was an nginx-side bug: every site's vhost did fastcgi_param HTTP_X_FORWARDED_PORT $server_port and unconditionally overwrote whatever the upstream proxy sent. Vite dev-server URLs (http://[::1]:5173/...) and RustFS / S3 / other loopback URLs (http://localhost:9000/...) were also leaking into LAN-shared pages and failing on the client device, including from the JSON-escaped form Inertia.js emits in data-page attributes. Toggling TLS on or off left running share listeners pointing at the old backend port until a manual restart. And under the hood, lerd secure / lerd unsecure had three drifting copies (CLI, dashboard, MCP); MCP was missing both the Stripe listener restart and the LAN share refresh, and any new step added today was at risk of being forgotten in two of the three places. The consolidation lands siteops.SetSecured as the single source of truth for the TLS toggle flow: every entry-point now calls one function with no per-caller variation, and all post-toggle work (Stripe webhook restart, LAN share rebind) goes through HTTP notifications to the daemon so adding a new step happens in exactly one place.
Fixed
- White page on
lerd sharefor gzipped responses. The tunnel share proxy was readingContent-Encodingand bailing out of body rewriting whenever the upstream returnedgzip, which is essentially every framework. The proxy now sendsAccept-Encoding: identityto the upstream and transparently decodes gzip when the upstream sends it anyway, so absolute asset URLs in HTML/CSS/JS finally get rewritten to the tunnel host. Mirrors what the LAN share proxy was already doing. - Ziggy and Symfony emitting
http://<lan-ip>:443/...URLs on LAN-shared sites. Two-layer fix: the LAN share proxy now forwardsX-Forwarded-Portto the upstream, and the nginx vhost template stops unconditionally overriding it with$server_port. A new shared_forwarded.confmap ($real_forwarded_port) reads the upstream header when present and falls back to$server_portonly when the request was direct. Both the SSL and non-SSL vhost templates were updated, and the running per-site confs were patched in place so the fix took effect immediately for all 12 sites without needing to re-link every one. - Vite dev server unreachable from LAN devices.
http://[::1]:5173/...URLs that the laravel-vite-plugin emits are rewritten in the response body tohttp://<lan>/__lerd_vite__/5173/..., and the share proxy forwards those paths to the local Vite. Vite HMR's WebSocket upgrade routes to the same listener (no per-projectvite.config.jschange needed). Transitive imports (/node_modules/...,/@vite/...,/@id/...,/@fs/...,/@react-refresh,/__vite_ping, plus framework source roots/resources/,/src/,/vendor/) reach Vite via the most recently observed Vite port for the share. Files with dev-only extensions (.vue,.ts,.tsx,.jsx,.mjs,.cjs,.svelte,.scss,.sass,.less,.styl,.astro,.mdx) and Vite-stamped query hints (?import,?worker,?raw,?inline,?url) route to Vite when a port has been observed. - RustFS / S3 / other loopback services on LAN-shared pages. The same body rewriter that catches Vite also normalises
http://localhost:9000/.../http://127.0.0.1:9000/.../http://[::1]:9000/...references to flow through the share proxy. Avatar images, Mailpit links, and any other dev-time loopback URL now load from LAN devices without per-project config. The rewriter also handles the JSON-escaped form (http:\/\/localhost:9000\/...) that ends up in Inertia.jsdata-pagepayloads. The handler only marks a port as the active Vite when the stripped path actually looks Vite-served, so an avatar fetch via/__lerd_vite__/9000/...no longer poisons subsequent/node_modules/lookups (which would otherwise hit RustFS and return400 InvalidBucketName). - Toggling TLS no longer leaves the LAN share pointing at the old backend.
lerd secure/lerd unsecure, the dashboard padlock, and the MCPsite_tlstool now refresh any running LAN share proxy so it re-binds to the new backend port (80 vs 443). Previously the share kept proxying to the previous port until manually restarted. - MCP
site_tlsnow syncs Vite Reverb env vars and refreshes Stripe / LAN share. Brought up to parity with the CLI:APP_URLplusVITE_REVERB_HOST/SCHEME/PORTget updated on every toggle (so browser Echo doesn't stay wedged onwss://host:80after going to HTTP), the Stripe listener restarts with the new scheme so its--forward-tokeeps working, and any running LAN share proxy re-binds to the new backend. Tool description updated to reflect the actual surface.
Changed
siteops.SetSecuredis now the single source of truth for the TLS toggle flow. CLI, dashboard, and MCP previously each had inline copies of the eight-step toggle dance (cert, registry, .env, .lerd.yaml, nginx reload, Stripe restart, LAN share refresh). They drifted: MCP only updatedAPP_URL, the dashboard never restarted Stripe, and the LAN share refresh existed only on the CLI side. The new helper runs all the core steps and posts to the daemon'sstripe:refreshandlan:refreshHTTP endpoints so any caller, including the daemon itself, drives the dependent listeners through the same code path. Adding a new step now happens in one place instead of three. CLI'srestartStripeIfActiveis exported ascli.RestartStripeIfActiveso the newstripe:refreshdaemon action can call it.- Tunnel share proxy in
lerd sharenow matcheslan:share's gzip + body rewriting behaviour. The two were close-but-not-identical implementations; the host proxy now uses the sameAccept-Encoding: identityupstream hint and gzip-decoding fallback the LAN proxy already had. - Loopback-URL rewriter terminator class includes
)and;so CSSurl(http://[::1]:5173/foo.png)declarations get rewritten too. Previously the regex only stopped on quote/slash/space, so background-image URLs in Vue scoped styles leaked through unchanged. Referer-based Vite routing was deliberately dropped. The LAN share listens on0.0.0.0, so trusting a Referer for the target port would let any device on the network craft aReferer: http://host/__lerd_vite__/22/anythingand steer the proxy at SSH or the database. Routing now uses only the in-processactiveVitePortthat gets set when a real/__lerd_vite__/<port>/request comes in. Active-port fallback was already covering every legitimate case the Referer path was hitting.- Speculative path prefixes (
/app/) and query hints (?v=) removed from the Vite router./app/was added defensively but breaks Filament admin URLs which sit under/app/.?v=hashis the most common cache-busting query on the web; matching it routed unrelated Laravel/Symfony assets to Vite. Both removals are safe because the explicit Vite prefixes (/@vite/,/node_modules/, etc.) plus the dev-only extension list already cover everything Vite actually serves.
[1.20.1] — 2026-05-14
A 1.20.0 follow-up for the curl|bash / wget|bash install path. When the installer script is piped through bash the script's own stdin is the pipe, not the terminal, and that pipe was inherited by lerd install so its [Y/n] prompts hit EOF the moment they were issued. The Node-management and DNS questions flashed past without ever showing up to the user, both silently defaulted to yes, and the mkcert step then jumped straight to a sudo password request that looked like it came out of nowhere. lerd install now reopens /dev/tty for its prompts whenever stdin is not itself a TTY, and install.sh separately redirects lerd install's stdin from /dev/tty when one is available, so the prompts reach the user from either side of the pipe. If neither a TTY stdin nor /dev/tty is available (unattended CI, containers without a controlling terminal) the prompts now print (no terminal, defaulting to yes/no) instead of vanishing.
Fixed
- Install prompts no longer skipped when piping the installer through bash (Ubuntu and other distros).
lerd installfalls back to/dev/ttywhen stdin is a pipe, andinstall.shhands/dev/ttytolerd installso the Node-management and DNS questions actually reach the user duringcurl … | bash/wget … | bashinstalls.
[1.20.0] — 2026-05-14
The 1.20.0 line makes git worktrees a first-class concept throughout the stack. Per-worktree workers are surfaced in CLI, dashboard, and TUI; a built-in Vite dev server worker runs on the host with auto-start per worktree; wildcard cert SANs cover deep subdomains; env_overrides templating handles multi-tenant apps; per-worktree dump tagging keeps the live viewer accurate; and the dashboard ships a manage-worktrees modal alongside an upgrade to Tailwind CSS v4. The TUI catches up to the dashboard with service update and rollback keybinds, a failing-workers header pill, and per-worktree controls in the site detail pane. PHP 7.4 and 8.0 land as a frozen legacy tier, lerd php:ext add learns --apk-deps for extensions that need extra Alpine build packages, the dashboard ships in German, Indonesian, and Dutch with a wide i18n pass across all seven locales, and MCP gains workers_mode, bug_report, and a branch parameter across its per-worktree CLI wrappers. The post-beta fix queue covers macOS phantom launchd workers restarting healthy units every cooldown, dashboard update-banner glitches, and the worktree-add modal's "Automatic" resolver now announcing what it picked instead of going silent.
Added
- Manage git worktrees from the site dashboard (#341). The site detail panel's path line gains a worktrees icon next to the branch picker that opens a modal for adding and removing worktrees, so the picker is no longer read-only. The modal lists each worktree with a link to its URL (https when the parent site is secured) and a trash button; Remove expands an inline confirm with Discard uncommitted changes (
--force) and, for isolated worktrees, Also drop database (off by default, matchinglerd worktree remove). Add worktree presents the same choices as thelerd worktree addprompts: a new branch (with optional base ref) or an existing branch (the dropdown lists local branches not already checked out plus remote-tracking branches, which git dwims into a fresh local branch), the database (share parent / isolated empty / cloned from main / cloned from another worktree / reuse-or-reset a preserved DB), a Laravel migrations checkbox, and the frontend-asset choice (asset worker /npm run <script>/ skip), and it streams thegit worktree add+ dependency-install + build + DB-setup output live. The checkout directory is picked automatically (<project-path>-<branch>). Backed by extracted non-interactive helpers ininternal/cli(RunWorktreeAdd,RemoveWorktreeAndCleanup,ApplyWorktreeDBChoice,ApplyWorktreeBuildChoice,RunWorktreeMigrations) shared with the CLI prompts, new HTTP endpointsGET /api/sites/worktree-options,POST /api/sites/worktree-add(SSE), aworktree:removesite action, and new locale strings across all seven languages. The site detail header also got a mobile-layout pass: the path andgit:(branch)picker stack on separate rows on narrow screens, and the pause / unlink / open / terminal buttons move into a left-aligned full-width row instead of crowding the top-right corner. - Per-worktree worker lifecycle as a first-class concept, with dashboard surfaces (#319). Two new framework-yaml flags:
per_worktree(worker can run independently per git worktree underlerd-<wname>-<site>-<wt>) andreplaces_build(while running, the worker provides the asset manifest so the staticnpm run buildstep is unnecessary). The two flags answer independent questions ("where can this run?" vs "do we still need a build?") so future host-mode workers can mix and match. The watcher'ssyncWorktreenow only auto-starts host workers the user opted into via.lerd.yaml workers:AND that declareper_worktree: true, replacing the previous blanket auto-start of everyhost: trueworker. Dashboard surfaces:/api/servicesenumerates worktree workers per site with newworker_worktree/worker_worktree_domainfields,/api/sitesworktrees carryframework_workers,SiteControlsrenders per-worktree toggles next to the parent toggles, andServiceDetailbuilds/api/worker/<site-wt>/<name>/logsURLs for worktree-suffixed unit names.lerd setupusesGetFrameworkForDirso store-defined workers (vite) appear in the toggles and falls back to the parent site when run inside a worktree.runLinkrejects worktree paths instead of registering them as separate top-level sites, eliminating the "main" entries that bled into the dashboard.lerd installrunsrefreshStoreFrameworksto re-fetch every cached framework yaml so users pick up the new flags without waiting for the 24h staleness check. - Built-in Vite dev server worker with host execution and worktree auto-start (#310). New
viteworker for Laravel runsnpm run devon the host via fnm rather than inside the PHP-FPM container, so Vite has direct filesystem access for HMR without port publishing or a proxy. A newhost: truefield onFrameworkWorkerlets future host-mode workers (Tailwind watcher, Encore, etc.) ship with the same execution profile. Per-worktree unit naming (lerd-vite-<site>-<wt>) allows simultaneous Vite instances across worktrees with auto-incremented ports. The vite definition lives in thelerd-frameworksstore (laravel/11-13.yaml), not in the Go binary, so future framework workers don't require a lerd release. env_overridesin.lerd.yamlfor worktree env templating (#308). Projects can declare templated or static env variables that resolve when worktrees are created, layered on top of the existingAPP_URLrewrite. Values can use,, andplaceholders or be plain strings. WhenAPP_URLis present inenv_overridesit takes precedence over the default rewrite; declared keys override defaults and undeclared defaults still apply. Multi-tenant apps that depend on session domains, tenant routing, or signed-URL hosts no longer need a manual.envpatch per worktree.- Automatic wildcard cert SANs and nginx
server_namefor worktree subdomains (#309). When a worktree is created on a secured site, lerd now includes*.branch.domain.testin both the TLS certificate SANs and the nginxserver_namedirective. Deep subdomains likeapp.branch.domain.testwork out of the box without manual cert regeneration. The parent cert is reissued with worktree wildcard SANs on creation, and the daemon-startup sync reissues certs that picked up worktrees added before the feature shipped. NewWorktreeCertDomainshelper builds the domain list andReissueCertForWorktreeis exposed for the daemon sync flow. - Unified asset-worker / npm-build prompt for
lerd worktree add(#321). The previous auto-skip-or-prompt branch is replaced by one select that lists every eligible asset worker (replaces_build+per_worktree+ check passes), every npm production-build script (build/prod/build:prod/build-prod/production), and Skip. Default prefers an opted-in asset worker, then the first npm script, then skip. Asset workers appear even when not in the parent's.lerd.yaml workers:list; picking one starts it ad-hoc withpersist=falseso the parent yaml stays the source of truth and the choice is per-session. - Worktree-add modal's "Automatic" resolution now announces its choice (#344).
applyWorktreeBuildRequestcalls a newlogAutoBuildResolutionwhenever the build request is""or"auto", emitting one explicit line stating which branch the resolver took and why. The asset-worker case names the worker and explains the.lerd.yaml workers:opt-in plusreplaces_buildrule; the script case names thenpm runtarget it's about to invoke; the skip case lists the production-build script names it looked for and warns aboutViteManifestNotFoundException.ApplyWorktreeBuildChoicealso gains a"skip"case so the modal never goes silent after the dependency-install phase, and CLI users hitting the huh "Skip" prompt see the same line. Users no longer have to read the buried per-kind log line and guess what "Automatic" picked. - Worktree-add warnings surface in the dashboard (#343).
RunWorktreeAddnow wraps its log writer in awarningCapturingWriterthat scans for[WARN]lines and returns them alongsidebranchandpath. The SSE done payload forwards them;WorktreeModalkeeps the log view open with an amber "completed with warnings" banner and a "back to list" button instead of silently jumping back to the list on a half-finished setup (db skipped, build script error, install timeout, etc.). Two new i18n keys added across de/en/es/fr/id/nl/pt. - Per-worktree dump tagging in the live viewer (#343).
LERD_SITEandLERD_BRANCHare plumbed through nginxfastcgi_param(worktree and parent vhosts) and throughpodman exec --envfromRunTinker. The dump bridge reads$_SERVERfirst, thengetenv(), so both FPM and CLI (tinker) requests get the same identifiers. The dump payload gains abranchfield across Go (internal/dumps/event.go), PHP, and the TypeScript stream type, so events from a worktree are no longer mis-grouped under the worktree's directory basename. - TUI service lifecycle keybinds (
uupdate,brollback). Pressinguwhile focused on the Services pane runslerd service update <name>for the highlighted service (no tag, applies the safe in-strategy update). Pressingbrunslerd service rollback <name>. Both fire and refresh the snapshot, mirroring the dashboard's Update / Rollback buttons. No-op on worker rows (queue-alpha, etc.) since they have no upstream image. Help reference (?) lists both. Migrate, remove, and reinstall stay CLI/dashboard-only for now since they need tag pickers or destructive-action confirmation. - TUI header pill for failing workers. When any framework worker (built-in queue/schedule/horizon/reverb, custom worker, or per-worktree worker) is in the systemd
failedstate, the TUI header renders⚠ N workers failing · H to healin failing-style colour next to the existing DNS / nginx / FPM / watcher pills. Mirrors the dashboard's amber sticky banner from #319. HelpercountFailingWorkersaggregates state across every site and worktree. - TUI per-worktree controls in the site detail pane. Each worktree row now expands to show its per-worktree workers (one row per
FrameworkWorkerin the worktree, e.g.viteon a Laravel branch), an "Isolated DB" toggle when the parent uses a managed database service (mysql/mariadb/postgres), a "LAN share" toggle, and PHP / Node version pickers. Pressing space/enter on a worktree-worker row runslerd worker start/stop <name>from inside the worktree's checkout, so the worktree-suffixed unit (lerd-<worker>-<site>-<branch>) is targeted; the DB row runslerd db:isolate/lerd db:share, the LAN row runslerd lan share/lerd lan unshare, and the PHP / Node pickers runlerd isolate <ver>/lerd isolate:node <ver>, all with cwd inside the worktree so the choices persist there rather than at the parent. Version rows show "(inherited)" in dim style when the worktree hasn't overridden the parent. Help reference (?) gains a Worktrees section. NewLANPortfield onsiteinfo.WorktreeInfopopulated fromconfig.FindWorktreeLAN. - MCP
site_php/site_nodegain abranchparameter. When set, the version pin is written inside the worktree's checkout andphp_version/node_versionis persisted to that worktree's.lerd.yaml(so the override travels with the branch in git); only the worktree's nginx vhost is regenerated. Withoutbranch, the existing parent-site flow runs unchanged. Routes throughlerd isolate/lerd isolate:nodewhich already handle the worktree case correctly viaFindParentSiteForWorktree. - MCP
workers_modetool. Wrapslerd workers mode <get|set>so AI assistants can flip the macOS worker runtime betweenexec(default; onepodman execper worker, supervised by launchd, lower memory) andcontainer(one detached container per worker, 1:1 supervisor boundary). Linux always uses exec under systemd, so the tool is a no-op there. Setting the mode on macOS stops each active worker in its old shape and restarts it in the new shape, so no manuallerd stop/lerd startis needed. - MCP
bug_reporttool. Wrapslerd bug-reportso agents helping users file GitHub issues can generate the diagnostic bundle (lerd doctor + config + systemd / podman state + recent logs + curated env vars) without leaving the chat. Site names, domains and parked-directory paths are anonymised by default;show_real_names: truekeeps raw values for local debugging. Returns the file path so the user can attach it to the issue. - PHP 7.4 and 8.0 as a frozen legacy tier. Old projects (Laravel 6–8 on 7.4, Laravel 8–9 on 8.0) can now run under lerd. They build from the same Alpine-based recipe as the current versions, including ICU full locale data; the only version-conditional bit is the Xdebug release (pinned to
3.1.6for 7.4,3.3.2for 8.0, since current Xdebug needs PHP 8.1+), andmongodbis unavailable on those lines. The base image is Alpine 3.16, so the bundled Node.js is 16.x. These are end-of-life upstream and get no security patches; they exist for local work on legacy apps only.lerd use 7.4,lerd isolate 8.0,lerd fetch 7.4 8.0all work; both versions are inbase-images.ymlandSupportedPHPVersions. lerd php:ext add --apk-deps "pkg ..."for extensions that need extra Alpine build packages (follow-up to #334, which already coversimap). Some PECL extensions need-devpackages that aren't in the base FPM image (ssh2needslibssh2-dev,memcachedneedslibmemcached-dev, …).--apk-depslets you supply them; they're saved per extension in~/.config/lerd/config.yamlunderphp.ext_apk_deps, deduped with the built-in set, and reapplied on everylerd php:rebuild.lerd php:ext listshows them, and the MCPphp_extadd action takes anapk_depsargument.- Dashboard now ships in German (
de), Indonesian (id), and Dutch (nl), and dozens of previously-hardcoded screens are now translatable in every locale. The three new locales get a full set of translated strings, wired into the inlang project, the language switcher, andLOCALE_LABELS/LOCALE_CODES. As part of the same pass the Dumps viewer, the Tinker tab, the site Overview/detail tabs, the service detail + reinstall/remove modals, the worktree-DB-isolate modal, and a batch of smaller components/modals were migrated off hardcoded English into them.*()catalog and translated across all seven locales (en/es/pt/fr/de/id/nl), so es/pt/fr benefit too. A vitest check (src/lib/locales.test.ts) guards every locale file against key drift fromen.json. Native-speaker review/polish welcome, especially forid.
Changed
- Upgraded the dashboard to Tailwind CSS v4 (#341). CSS-first config (
@themeinapp.css, notailwind.config.js), the v4 PostCSS plugin (@tailwindcss/postcss,autoprefixerdropped), class-based dark mode preserved via@custom-variant, and the v3-compat border-color base layer. All utility renames applied across the component tree (outline-none→outline-hidden,rounded→rounded-sm,ring→ring-3,shadow-sm→shadow-xs, etc.). config.SiteSlugconsolidates DB-safe name conversion (#315). The repeatedstrings.ToLower+ReplaceAll("-","_")pattern acrossinternal/cli/env.go::projectDBName,internal/cli/worktree_db.go::WorktreeDBName,internal/serviceops/reprovision.go::resolveDBName, andinternal/mcp/server.go::db_setis now a single helper that also handles dots so domain names slugify through the same path. Pure refactor, no behaviour change.- MCP
worker/worker_listtools gain abranchparameter and route through the lerd CLI. Previously the MCPworkertool inlined its own systemd unit content (nohost: truesupport, hardcodedlerd-<worker>-<site>unit name) so callingworker(action: "start", worker: "vite")quietly wrote a non-functional unit and ignored worktrees entirely. The handlers now shell out tolerd worker start/stopwith the cwd set to the parent site path or the resolved worktree path whenbranchis given, inheriting the CLI's worktree-aware naming, host execution, conflict handling, and persistence.worker_listreports the newhost/per_worktree/replaces_buildflags and acceptsbranchto switch between parent and per-worktree unit state. The injected SKILL / Junie guidelines / Cursor rules describe the new param so AI assistants drive per-worktree workers correctly. MakefileUI deps gain a stamp file (#343).node_modules/.package-lock.jsonis now an explicit target depending onpackage-lock.jsonpluspackage.json, sonpm ciruns only when the lockfile orpackage.jsonchange instead of everymake build/make test-ui.
Fixed
- macOS phantom launchd workers restarted healthy workers every cooldown (#343).
workerNeedsHealingandsweepOrphanWorkerArtifactswere looking for~/Library/LaunchAgents/lerd.<unit>.plist, but the file on disk is<unit>.plist(thecom.lerd.prefix lives only on the launchd Label inside the plist, seeservices.plistPath/plistLabel). The heal loop therefore saw every unit as "plist missing" and restarted healthy workers each cooldown; the orphan sweep simultaneously failed to keep real orphan.sh/.pidfiles. Both now read the correct path.isExecModeUnitgets the same fix ininternal/tui/log_tail_darwin.go. - vite toggle and log tab vanished from parent site rows after the per-worktree worker pass (#346). The siteinfo guard skipped
IsPerWorktree()workers fromenrichWorkersunconditionally, removing the vite toggle and log tab from sites that still run the worker at the parent level (no worktrees, or worktree-using parents that want vite opted in for project-level control). The check rule on the worker (file: node_modules/vitefor the Laravel store yaml) is already a tight enough gate, so the per_worktree skip was just removing correct rows along with the perma-inactive ones. The gate is gone; the test file now pins both directions. lerd install(andlerd update, which runs install) briefly dropped.testresolution on every run. The installer unconditionallysystemctl restartedlerd-dns, which tears down and recreates the podman container, a few-second window where port 5300 isn't bound and*.testwon't resolve. It now diffs the dnsmasq config (lerd.conf) and thelerd-dnsquadlet against what's already on disk and only restarts when one of them actually changed (or the container isn't running); a no-op reinstall, the common case after a version bump, leaves the live container alone. NewfileChangedByhelper wraps the read-mutate-read diff. macOS (native dnsmasq, no container teardown) is unchanged.- Dashboard "Open terminal & update" button failed with
lerd: command not found. The handler shelled out viash -c "lerd update; …", but the spawned terminal inherits a non-login shell environment that doesn't source.bashrc/.zshrc, so~/.local/binis offPATH. The script now usesos.Executable()to resolve the absolute path of the runninglerd-uibinary (which is thelerdbinary itself) before passing it to the shell. Same fix pattern the TUI'srunLerdalready uses. - Dashboard update banner showed
Lerd vv1.19.2 is available. Two bugs compounded: the wire payload from/api/versionreturned the GitHub tag verbatim (with leadingv) while the Svelte templateLerd v{version} is availablealready prependsv, producing the double-v.handleVersionnow strips thevvialerdUpdate.StripVbefore sending so the wire data is bare and the template renders cleanly. internal/updatetest pollution rewrote the user's realupdate-check.json.withTempCachesetXDG_CONFIG_HOMEbutconfig.UpdateCheckFilereadsXDG_DATA_HOME, so test cache writes leaked to~/.local/share/lerd/update-check.jsonand surfaced bogus version tags (e.g.v1.19.2) in the dashboard banner for 24h. Test now sets the correct env var so writes stay in the temp dir.- Stable users got "update available" prompts when a beta was published. GitHub's
/releases/latestredirect normally excludes prereleases, but a brief redirect-cache window (or a release accidentally not marked prerelease) can land a beta tag incachedLatest(), whichCachedUpdateCheckthen surfaces to every stable user.CachedUpdateChecknow skips the notification when the cached latest carries a prerelease suffix and the current version doesn't. Beta-on-beta upgrades still surface, and stable-to-stable is untouched. NewIsPrereleasehelper plus four pinned tests cover the matrix. lerd framework updateonly refreshed the latest cached version (#318). The command iteratedListFrameworksDetailed(one entry per<name>@<version>) but always fetchedentry.Latestfrom the store. A user withlaravel@10/11/12/13cached only gotlaravel@13refreshed; older cached versions stayed stale until the 24h auto-refresh ran orlerd installtriggered a fetch. The loop now fetchesinfo.Versionso each cached file is refreshed individually.lerd secure/lerd unsecureleftVITE_REVERB_*stale, breaking Reverb WebSocket on secured sites. Toggling HTTPS only updatedAPP_URLviaenvfile.UpdateAppURL.VITE_REVERB_HOST/SCHEME/PORTkept their pre-flip values, so a site secured afterlerd envended up withVITE_REVERB_SCHEME=http/VITE_REVERB_PORT=80while the browser was on HTTPS. Vite bakes those into the built JS, so Echo triedwss://host:80(TLS WebSocket on the HTTP port) and the connection failed silently every page load. The same omission affected the dashboard's secure/unsecure handlers and the per-worktree paths insideSecureSite/UnsecureSite. All five call sites now useenvfile.SyncPrimaryDomain, which has been the canonical helper for this since #209 (April 10) and was already used bylerd domain add/removeand TLD migration.TestUpdateEnvAppURL_syncsViteReverbVarsandTestUpdateEnvAppURL_unsecureWalksViteReverbBackpin the regression. The bug predatesSyncPrimaryDomainitself (introduced in v0.3.0, 2026-03-18), so existing secured Laravel sites withBROADCAST_CONNECTION=reverbneed to rebuild Vite assets after this update.- Worktree wildcard cert SANs only refreshed on per-worktree sync, not on bulk regen.
syncWorktreereissued the cert when a single worktree was added, butscanWorktrees(daemon-startup discovery) andcleanupWorktreeVhosts(post-cleanup regen) wrote*.branch.domain.testinto the nginxserver_namewithout reissuing the cert, so deep subdomains likeapp.branch.domain.testfailed TLS after a daemon restart that picked up worktrees added before the wildcard-SAN feature shipped. Both bulk paths now reissue once per site; cleanup also shrinks the cert SAN list when worktrees are removed. - A transient mkcert failure during cert reissue could downgrade a secured site to plain HTTP.
issueCertWithWorktreesremoved the existing.crtand.keyfiles before invoking mkcert so the new SANs would take effect; if mkcert hiccuped (binary missing after an update, permission drift, anything), the site was left with no cert files and the next start trippedRepairVhostsinto flippingSecured=false. The reissue path now writes mkcert output to*.newtemp paths and renames atomically, so a failure leaves the previous cert intact. NewIssueCertForceexposes the atomic flow andTestIssueCertForce_failureLeavesExistingCertIntactregression-pins the safety property by simulating a failing mkcert binary. - Dashboard DNS dot stayed red after boot until manual refresh. When
lerd-uicame up beforelerd-dnsfinished starting, the initial/api/statussnapshot baked indns.ok=false. Once DNS naturally became reachable a few seconds later nothing pushed an update to the live WebSocket, so the DNS pill on the dashboard stayed red until the user reloaded. The first attempted fix added the publish toWatchDNSin the watcher binary, butlerd watchandlerd serve-uiare separate processes andeventbus.Defaultis per-process, so that publish never reached the broker. The actual fix lives ininternal/ui/dns_status_watcher.go: a small in-process probe runs insidelerd-ui, ticks every 30s while a tab is visible, compares against the last observation, and publisheseventbus.KindStatuson transition.runSnapshotInvalidatorthen rebuilds the status snapshot and the broker pushes it to every tab. Cost is one localnet.LookupHostper 30s when the dashboard is open, zero work when no tab is open. TheWatchDNSimmediate-probe-at-startup change still applies and shaves 30s off the first repair tick. Table-driven tests cover the boot-up-publish, boot-down, steady-up, steady-down, recovery, regression, and idle-skip paths.
[1.19.3] — 2026-05-11
A small patch release: lerd php:ext add no longer reports success when the extension's build actually failed.
Fixed
lerd php:ext add <ext>reported success even when the PECL build failed (#334). The custom-extension RUN block ends in|| trueso a broken build doesn't brick the whole image on laterlerd php:rebuildruns, but that also meantlerd php:ext add imapprintedExtension "imap" installedwhilephp -mshowed nothing.lerd php:ext add(and the MCPphp_extadd action) now runphp -min the rebuilt image to confirm the extension loaded; if it didn't, the command exits with an error and removes the extension from~/.config/lerd/config.yamlagain instead of leaving a phantom entry.- Some extensions failed to build because their Alpine build deps weren't present (#334).
imapin particular needsimap-dev krb5-dev openssl-dev c-clientor PECL aborts withutf8_mime2text() has new signature, but U8T_CANONICAL is missing. lerd nowapk adds the required packages beforepecl installfor extensions it knows about (currentlyimap); the map is easy to extend for others.
[1.19.2] — 2026-05-11
A one-fix patch release: locale-aware formatting now works inside the PHP-FPM containers.
Fixed
NumberFormatter/ locale-aware currency and number formatting produced English output (€ 13,943.20instead of€ 13.943,20) inside PHP-FPM containers (#332). The Alpine PHP image only bundledicu-data-en(Alpine splits ICU's locale database into a separate package), soext-intlsilently fell back to the root/en locale for every non-English locale, breakingNumberFormatter('nl_NL', …),IntlDateFormatter, Laravel'sNumber::currency()and money formatting, and any test that asserts localised output. The Containerfile now installsicu-data-full, which carries the full CLDR locale set. This bumps the Containerfile hash so existing images rebuild on next use (orlerd php:rebuild). Note this does not change the C-librarysetlocale()/localeconv()path: musl libc does not implement non-CLC_NUMERIC/LC_MONETARY, so apps relying on locale-aware formatting should useext-intl(NumberFormatter), which is locale-data driven and unaffected by the system locale.
[1.19.1] — 2026-05-07
A maintenance release rolling up the post-v1.19.0 fix queue: Podman 4.x compatibility on Ubuntu 24.04, MySQL 8.4 client + driver compatibility for Laravel sites, broader system-node detection so users on nvm/volta/mise/asdf/fnm aren't quietly shimmed, host-port conflict surfacing on stopped services, and a .lerd.yaml honour fix on Laravel lerd init.
Fixed
- Service quadlets failed to generate on Podman 4.x (#299, #302). v1.19.0 emitted
StopTimeout=5in every service's[Container]section, but that key was added in Podman 5.0 and is unrecognised by the 4.9.3 shipped on Ubuntu 24.04. systemd-quadlet aborted with exit 1 and produced no service units, solerd-mysql.service,lerd-redis.serviceetc. simply didn't exist. Lerd now probespodman --versiononce and falls back toPodmanArgs=--stop-timeout=5on Podman <5.0, which is universally supported and produces the same--stop-timeout=5on the underlyingpodman run. lerd installfailed creating the lerd podman network on Ubuntu 24.04 (#299, #304). After creating the bridge, lerd ranpodman network update --dns-add <ip> lerdfor each upstream DNS server (e.g. libvirt's192.168.122.1), but on Ubuntu Noble's netavark <1.11 that command needs the per-network aardvark-dns runtime file to already exist, which it doesn't until a container has connected. The result was a hard install failure ending innetavark: unable to modify network dns servers: IO error: No such file or directory. Lerd now passes--dns <ip>flags atpodman network createtime, so the DNS servers are written into the netavark JSON in the same atomic step as the subnet and MTU. The post-create drift sync still runs but is now a no-op on a fresh install.--no-ipv6is no longer needed as a workaround.- Dashboard could go green for a service with no unit (#299, #301, #302). When a quadlet generator rejected a
.containerfile but a container from an earlier valid load was still in the cgroup,systemctl --user list-unitsprintednot-found active runningand the dashboard read theactivecolumn verbatim. The unit-state cache now collapses any LOAD other thanloaded(not-found,masked,bad-setting,error) toinactiveso the UI matches reality. - MySQL 8.4 was unusable from Laravel CLI tools (#303). The PHP-FPM container ships Alpine's
mysql-client(which is the MariaDB client), and MySQL 8.4 broke it on two fronts. First, MySQL 8.4 auto-generates self-signed TLS certs that the MariaDB client refuses to validate, breaking everymysql/mysqldumpinvocation Laravel and packages likespatie/db-dumpershell out to. Second, MySQL 8.4 disablesmysql_native_passwordby default and the MariaDB client doesn't speakcaching_sha2_password, so the CLI couldn't authenticate even with TLS off. Lerd now ships/etc/my.cnf.d/lerd-no-ssl.cnfinside the PHP-FPM image (full and fast-path builds) so the MariaDB client connects in plaintext over the trusted lerd network, and the MySQL preset config addsloose-mysql_native_password=ONso the auth plugin is loaded on startup. Theloose-prefix keeps this config compatible across MySQL 5.6 / 5.7 / 8.0 / 8.4. Also disabledrestrict_fk_on_non_standard_key(new in 8.4, ON by default) which was rejecting existing dumps with foreign keys against non-unique columns, and addedffmpegto the PHP-FPM image for media-library packages that depend on it. lerd initon a fresh Laravel project ignored the.lerd.yamlDB pick (#305). Picking MySQL or Postgres at init time saved the choice to.lerd.yamlbut never updated.env, so Laravel keptDB_CONNECTION=sqliteand the user's selection silently dropped on the floor. Laravel definesEnv.Services, sorunEnvtook the framework path which only applied services it could detect already in the.env; the non-framework branch already honoured.lerd.yaml. The yaml-honour decision is now extracted intoshouldApplyService/userPickedDBFromYAMLhelpers and reused on both paths, with table tests covering the regression case (yaml says mysql, env doesn't have it, still apply mysql).- Stopped services with port conflicts had no visible cause (#300). When a service unit was installed but stopped and another process held its host port (a system-installed Postgres on 5432, a stray Docker container, etc.), Start failed with a generic bind error and the user couldn't tell why. Lerd now surfaces the conflict passively in three places before they click anything:
lerd doctorgains a[Stopped service ports]section that walks installed-but-inactive services and warns per bound port;/api/servicessnapshots includeport_conflictsso the dashboard renders an amber pill on the service detail and a small alert icon in the sidebar list; and the port-finding hints inlerd startpre-flight, doctor, dns:diagnose, and the docs now branch on GOOS so macOS users get anlsofcommand instead of the Linux-onlyss -tlnpstring. The cli port helpers (PortInUse,PortInUseIn,PortListOutput,PortCheck,CollectPortChecks) are now exported sointernal/uican reuse them without duplicating the ss-vs-lsof logic. - Lerd silently shimmed Node on hosts using nvm / volta / mise / asdf / fnm (#297).
detectSystemNodeonly probednodein PATH, so a developer with their own version manager (whose shim is loaded later in shell rc) wouldn't see the new "Let lerd manage Node.js?" prompt and would end up with~/.local/share/lerd/bin/{node,npm,npx}masking their tooling. Detection now also probesnpm/npxand the well-known version-manager dirs, so those users get prompted instead. After confirming managed Node, install also runsfnm install+fnm defaultso the user is left with a workingnpm(fixes the "can't find version: default" surprise on first run); the shim now probes the default alias before exec to catch the "versions installed but no default" case alongside an empty list.lerd node:install/node:use/node:uninstallwarn and require confirmation when lerd isn't currently managing Node, and write fresh shims on accept so CLI opt-in matches the install flow. The dashboard NodePage install input/button is disabled when Node is system-managed, with a hint pointing atlerd install, and the UI endpoints have matching server-side guards. - Opting out of lerd-managed Node left fnm shims masking system node (#306).
lerd installadds a "Let lerd manage Node.js?" prompt when a system node is detected; answering no on a host that previously had managed node skipped writing fresh fnm shims but didn't remove the existing ones, so~/.local/share/lerd/bin/node|npm|npxkept overriding system node in PATH. The opt-out path now deletes the three shims so the user's choice actually takes effect. - Podman version probe missed Homebrew under launchd (#306, macOS). The
StopTimeout=vsPodmanArgs=--stop-timeout=5selector ranexec.Command("podman", "--version"), which fails under launchd's restricted PATH wherepodmanis at/opt/homebrew/bin/podman. The probe now goes throughPodmanBin()so launchd-spawnedlerd-uipicks the modern key on Podman 5.x instead of always falling back.
[1.19.0] — 2026-05-04
A large feature release. Highlights: a brand-new Svelte 5 dashboard with a command palette, every default service moved to YAML presets, a first-class update / migrate / rollback / reinstall flow with linked-site reprovisioning, the in-browser Tinker REPL, optional disabled-DNS install mode, worker self-heal, full git-worktree support, and macOS parity for the Linux-only pieces of the v1.19 surface.
Added
Services and data lifecycle
- YAML-driven default service presets. The six built-in services (mysql, postgres, redis, meilisearch, rustfs, mailpit) moved out of hardcoded Go lists and embedded
.containertemplates intointernal/config/presets/*.yamlfiles markeddefault: true. Adding or replacing a default service is now a YAML edit; default and add-on presets share one quadlet writer, one env-var resolver, and one dependency engine. Six duplicated service-name lists collapsed intoconfig.DefaultPresetNames(); three duplicated env-var maps collapsed intoconfig.DefaultPresetEnvVars(name). - MySQL canonical bumped to 8.4 LTS (was 8.0). Existing users on saved 8.0 are untouched (viper merge wins;
migrateStaleServiceImagesskipped fortrack_latestpresets); fresh installs land on 8.4.x. The 5.6 alternate is gone; 5.7 and 8.0 remain pickable.mysql.cnfloose-prefixes added so the same config file works across mysql 5.6 / 5.7 / 8.0 / 8.4 (8.4 hard-rejectsinnodb_large_prefix/innodb_file_formatwithout the prefix). - Update, upgrade, migrate, and rollback flow. New
serviceops.UpdateServiceStreaming,serviceops.MigrateService,serviceops.RollbackService, plus aninternal/registrypackage that queries Docker Hub and GHCR for newer tags. Per-presetupdate_strategy(patch/minor/rolling/none),track_latest(fresh installs resolve current upstream), andallow_major_upgrade(gates cross-major NewestStable).- CLI:
lerd service update <name> [tag],lerd service migrate <name> <tag>,lerd service rollback <name>.lerd service listgains an Update column with green and amber badges showing pending updates. - Web UI: green Update, amber Upgrade, violet Migrate (mysql / postgres / mariadb only), grey Rollback buttons in the service detail panel. Streaming NDJSON phase events into the in-flight UI machinery.
- MCP:
service_check_updatesfor read-only status;service_controlaction enum widened toupdate,migrate,rollback,restart,remove,reinstall.service_removeandservice_updateremoved as standalone tools.
- CLI:
- Remove and reinstall any service, including default presets (#294).
lerd service remove <name> [--purge]and a newlerd service reinstall <name> [--reset-data]work on default presets too.--purgeand--reset-datarename the data dir aside (<dir>.pre-remove-<unix-nanos>) so the wipe stays recoverable. With--reset-data, reinstall auto-reprovisions per-site state on the fresh container: CREATE DATABASE for mysql/mariadb/postgres (resolving names via.lerd.yaml db.database, then.env DB_DATABASE, then a derived site name),mc mbfor rustfs (using.env AWS_BUCKETor the derived site name). New web UI modals for both flows; default-service deletes that affect linked sites require typing the service name to confirm. - Migration safety guards. Rolling-tag updates suppressed when local manifest digest matches remote (no phantom badges on
:latest). Cross-major upgrades hidden from the Upgrade button by default; opt-in via per-presetallow_major_upgrade. Cross-strategy upgrade button suppressed forupdate_strategy: patchpresets without a registered SQL migrator (Meilisearch, where clicking would brick the data dir). Alternates installed via preset (e.g.mysql-8-0) internally promote topatchstrategy so they don't get auto-suggested cross-LTS jumps. internal/registrypackage.ListTags,MaybeNewerTag,NewestStableagainst Docker Hub and GHCR with a 6-hour disk cache. Typed*UnreachableErrand*UnsupportedRegistryErrare swallowed by the high-level helpers so offline or unsupported-registry installs stay quiet rather than spamming errors./api/services/<name>/{updates,update,rollback,migrate,reinstall}. Read JSON plus streaming NDJSON endpoints mirroring the existing preset-install streaming flow.- Gotenberg API preset for PDF generation and document conversion (#268, #271).
Web UI and developer surfaces
- Dashboard rewritten in Svelte 5 + TypeScript (#260). The previous 4,800-line Alpine.js monolith (
internal/ui/index.html) is replaced by small composable components per tab, stores, and modals underinternal/ui/web/. Every feature from the Alpine version is preserved; bundle ships as a single hashed JS + CSS file embedded in the Go binary (~60 KB gzipped JS, 7 KB gzipped CSS). Vite build runs beforego buildviamake build-ui. No backend or API changes; the WebSocket snapshot protocol and/api/*routes are identical. - Dashboard root with live widgets and global command palette (#280). Replaces the empty "select a site" landing page with a Dashboard tab built around at-a-glance widgets backed by the existing WebSocket pipeline. Eight focused cards in a 3-col grid: HeroStatus alert strip, Sites list, Services bounded grid with update banner, Workers per-group active counts, System Health (PHP-FPM and Node usage), Resources (CPU + memory via a new
/api/statsendpoint that parsespodman stats --no-stream), Lerd info merged with a Recent activity timeline driven by a frontend diff over WS snapshots, and a dismissible Onboarding panel when no sites are linked. Plus a global command palette (Cmd+K, Ctrl+K, or/) for jumping to any site, service, page, or action from any tab. - Tinker tab, in-browser PHP REPL per site (#282). CodeMirror-backed editor with context-aware autocomplete (project models from PSR-4, composer helpers, PHP built-ins cached per version, framework hints, buffer variable completion), live
php -llinting (debounced 2.5s, symbol cache per domain to keep lerd-ui CPU flat under heavy typing), and an editor-styled output panel with collapsible Symfony VarDumper trees. Driven by the framework definition'stinker:block (command,execute_flag,execute_positional,requires_package,requires_file); Laravel ships with one bundled, sites without a framework REPL run a temp script under plainphpwith composer autoload included. Bare expressions auto-dump per statement. lerd bug-reportcommand for GitHub issue triage (#266). Collects logs only for lerd's own infra units (lerd-nginx,lerd-ui,lerd-watcher,lerd-dns,lerd-tray,lerd-autostart,lerd-fpm-init); preset services keep their state in the unit-state and container tables but the noisy logs no longer ride along. Anonymizes site names, domains, parked-directory paths, and the username by default (site-1,site1.<tld>,$PARK_1,$USER); pass--show-real-namesto keep raw values for local debugging.
Worker management
- Worker self-heal across CLI, dashboard, TUI, and MCP (#279). New
lerd worker heal [name]resets the failed state and restarts every worker unit systemd lists asfailed(or one named unit). Same recovery is reachable from an amber sticky banner in the dashboard, theHkeybind in the TUI, and theworkers_heal/workers_healthMCP tools.lerd statussurfaces a one-line hint when any worker is in failed state. Heal is intentionally narrow: it never writes.lerd.yamlor rewrites the unit file. The dashboard banner is push-driven over WebSocket via a 5-second cached-detector watcher that publishes only when the unhealthy set changes. lerd workers mode <exec|container>on macOS (#218). Framework workers (queue, schedule, horizon, reverb, custom) run via a pid-file-guardedpodman execinto the shared FPM container by default (execmode), matching the memory profile of the Linux systemd path. Each worker adds near-zero RAM compared to the previous per-worker-container model. Users who prefer the previous 1:1 supervisor boundary can opt back in withlerd workers mode container. Setting is also surfaced in the TUI settings overlay (S) and viaGET/POST /api/settings/worker-modeinlerd-ui. The dedup guard prevents duplicate workers when the podman-machine SSH bridge hiccups: if a previouspodman execis still alive, launchd's relaunch exits cleanly. No change on Linux.
Networking and DNS
- Optional disabled-DNS install mode for users who don't want lerd touching system DNS.
lerd installasksLet lerd manage DNS for local sites (No: use *.localhost, no dnsmasq, no HTTPS)?after the Node prompt and persists the answer underdns.enabled. When disabled, the installer skips lerd-dns, dnsmasq config, the sudoers rule, the mkcert root CA install, and the resolver tweak.lerd startno longer manages lerd-dns,lerd doctorreports DNS as managed externally, and the dashboard hides per-site HTTPS toggles. HTTPS is intentionally coupled because mkcert never installed a trusted CA in this mode. Re-runninglerd installwith the opposite answer detects the TLD change, lists affected sites, and offers a one-pass migration (registry,.lerd.yaml, project.env, git-worktree vhosts and per-worktree.env, stale primary vhost confs, TLS cert/key on disable). Suggested by #281. - Layered DNS diagnostic in
lerd doctorand andns_diagnoseMCP tool. Walks the chain end to end (lerd-dns container, dnsmasq config file, port 5300 listening, direct dig at 127.0.0.1:5300, resolver hookup script or drop-in installed, interface routing inresolvectl status, system-wide DNS lookup) and surfaces exactly which rung is broken with a one-line remediation hint per failure. Replaces the old single "not resolving to 127.0.0.1" error that conflated seven possible causes. The MCP variant returns the structured walk as JSON (steps[].status,first_failureindex) so AI assistants can drive troubleshooting without scraping doctor's output. Inspired by #285.
Git worktrees
- Worktree branch renames are picked up automatically (#264). The watcher monitors each
.git/worktrees/<name>/HEADfor writes, so agit branch -morgit checkout -binside a worktree re-syncs the nginx vhost and.envAPP_URLto the renamed branch without a manual restart. Stale subdomain vhosts are removed surgically (only the now-stale one), not by regenerating every vhost on the site. Thanks to @ropi-bc for the contribution. APP_URLrealigns to the worktree domain on every scan (#263). PreviouslyEnsureWorktreeDepsonly rewroteAPP_URLwhen it first created the.env; renames and external workflows that relied on a manualgit branch -mleft it pointing at the stale subdomain. The worktree scan now updatesAPP_URLon existing.envfiles too. Thanks to @ropi-bc for the contribution.lerd worktree addandlerd worktree removeinteractive wrappers mirroringgit worktree's subcommand layout, with every flag passing straight through to git.addpolls until the watcher's install pipeline (composer + npm) finishes, then prompts for the production-build script (skip /build/prod/build:prod/build-prod/production, whichever exist inpackage.json), and for the database setup (share parent / isolated empty / clone from main / clone from another isolated worktree). When the worktree's branch already has a preserved isolated DB the prompt prepends two extra options at the top: Reuse preserved isolated DB (reconnects without touching the schema) and Reset preserved DB to a fresh empty schema. Picking "isolated empty" then offers to runphp artisan migrate --force.removerunsgit worktree remove, recovers from "modified or untracked files" by offering a force-retry select prompt, then asks at the end whether to drop the worktree's isolated database (default Keep, so re-adds reconnect to the same data).- Opt-in per-worktree database isolation in the dashboard. The site controls expose an Isolated DB toggle whenever a worktree is active and the parent uses a lerd-managed mysql, mariadb, or postgres service. Flipping it on creates
<parent_db>_<sanitized_branch>in the same service container, rewritesDB_DATABASEin the worktree's.env, and persists the choice asdb_isolated: truein the worktree's.lerd.yaml(so it travels with the branch in git). On enable, lerd asks how the new schema should be seeded: empty, cloned from the parent (mysqldump --single-transaction | mysqlorpg_dump | psqlend-to-end inside the service container), or cloned from another already-isolated worktree. Cleanup ordering is vhost first, LAN-share second, isolated database last. - Per-worktree LAN-share proxy with a per-user registry at
~/.local/share/lerd/worktree-lan-shares.yaml. The dashboard's LAN toggle is worktree-aware;LANShareStartWorktree/LANShareStopWorktreeallocate ports across the same pool the parent uses, the proxy targets<branch>.<parent>.test, and the QR-code popover passes?branch=so the encoded URL is the worktree's. Cleanup ongit worktree removePOSTslan:unshare?branch=to lerd-ui so the listener closes in the daemon's process. - Worktree-scoped site detail view in the dashboard, with per-worktree PHP and Node versions. The path line carries an inline branch picker (
/path · git:(main) 3 ▾) that collapses to a single line regardless of worktree count and opens a dropdown listing the main checkout and every active worktree with its derived domain. Picking a branch re-scopes the rest of the panel to that worktree: the site title and the Open / Terminal buttons target the worktree's domain and checkout path, the App logs tab tailsstorage/logsfrom the worktree's directory (/api/app-logs/{domain}?branch=<sanitized>), and the PHP / Node version selectors show the worktree's effective version with a dashed violet border when inherited from the parent. Changing a selector while a worktree is active writes a worktree-only override to.lerd.yamlinside the worktree's checkout, so the choice travels with the branch in git. - Worktree inheritance for
lerd phpandlerd node:php.DetectVersionandnode.DetectVersionconsult a path-onlyconfig.ParentSiteForWorktreeDirlookup after.php-version/.node-versionand beforecomposer.json/package.jsonconstraints, so a worktree without an explicit override inherits the parent's pinned version instead of the highest installed satisfier. Composer's^8.2no longer silently picks PHP 8.5 on a worktree of a parent pinned to 8.4. - Watcher race fix for fresh worktrees:
handleNewEntrypollsHEADuntil it's a final ref or SHA before firingonAdded, closing a window where fsnotify Created the entry dir before git finalised HEAD. The daemon's startupscanWorktreespass also reconciles stale*.<site>.test.conffiles now, so a worktree removed while the watcher was offline gets its orphan vhost dropped on next start.
Changed
- Web UI cache poll backs off when the desktop session is idle or locked (#255). The
podman pscache that drives the dashboard already drops from 15s to 60s when no tab is visible. It now also drops to 60s while at least one tab is visible but systemd-logind reports the session as idle or locked, so a focused tab on an unattended laptop stops paying the per-15s subprocess cost. Recomputes on every transition via a 30s logind poll. Linux only; macOS keeps the visibility-only behavior via the existingSessionIsIdleOrLockedstub. lerd service restartrefreshes the quadlet before restarting, same path asstart, so config edits and preset file mounts (mysqllerd.cnf) land on disk before the unit picks them up. Previously a stale quadlet from an earlier release could keep running until an explicit stop+start.applyServicesin the Web UI refreshes every server-supplied field on each WS push (was onlystatus/pinned/site_count), so update / upgrade / version state propagates over the socket without a page reload. Client-only flags (loading,error,flash) still survive across pushes for in-flight UI state.internal/podman/quadlets/lerd-{mysql,redis,postgres,meilisearch,rustfs,mailpit}.containerdeleted; quadlets are now generated from preset YAML on demand.internal/cli/service_image_{darwin,linux}.godeleted; platform image overrides moved intoPreset.PlatformOverrideswith optionaltemplate substitution sotrack_latest-resolved tags survive the platform swap.
Fixed
Service lifecycle (the new update / migrate / rollback flow)
- Concurrent update / migrate / rollback now serialised per service (
internal/serviceops/locks.go). Double-clicking the Update button, or a CLI run racing the dashboard, can no longer interleave config writes, image pulls, and data-dir swaps. persistImageChoiceandswapImagePinare atomic with quadlet regeneration. If the on-disk quadlet write fails after the config write, the config is rolled back so~/.config/lerd/config.yamland the generated.containerfile can't disagree. Joined-error output if the rollback itself fails.- Migrate failures restore the pre-migrate data dir. A new
abortMigratehelper bubbles errors fromrestoreDataDirFromBackupand restarts the old unit when appropriate. - Rollback after a Migrate is refused. New
LastOpandPreMigrateBackupfields onServiceConfig/CustomService.RollbackServiceerrors out when the last op was a migrate (running the old binary against the upgraded data dir would corrupt it). The dashboard hides the Rollback button via a newcan_rollbackfield on/api/services. - SQL dump credentials no longer leak through argv.
mysqldumpandpg_dumpallreceiveMYSQL_PWD/PGPASSWORDviapodman exec --env, not on the command line. Dump files now open with mode0600; the backups directory is0700. - 30-minute timeout on every in-container migrate exec. A wedged container can no longer block migrate forever;
containerExec/dumpToHost/restoreFromHostuseexec.CommandContextwith a hard cap. allow_major_upgradeis enforced even when the CLI passes a tag explicitly. A newenforceMajorUpgradeGatecheck refuseslerd service update mysql 9.0for presets whereallow_major_upgrade: false. The registry-recommendation gate could previously be bypassed by direct tag invocation.- Server-side NDJSON streams stop on client disconnect. A new
startNDJSONStreamhelper short-circuits writes oncer.Context()is cancelled orw.Writereturns an error, replacing four copies of the closure in update / migrate / rollback / preset-install handlers. - Client surfaces stream errors instead of freezing the spinner.
streamServiceActioncallssetProgresswith phaseerrorbefore continuing, so a failed update shows the message in the UI instead of leaving the inline status stuck on the last visible phase. - Update-button gate uses strict equality.
migration_supported === false/=== trueandcan_rollback !== falseso a missing field doesn't accidentally show the wrong button. JSON tags loseomitemptyon those bool fields so the false case is always wire-visible. lerd service restartwarns instead of failing when quadlet regeneration trips. A transient template error no longer strands a healthy unit; the restart proceeds against the existing on-disk quadlet.lerd updateno longer silently bumps preset minors past the user's installed version.EnsureDefaultPresetQuadletreads the existing on-disk image whenupdate_strategyispatch/minor/noneand the user has no explicit pin, solerd update(which callsinstall --from-update) keeps users on their current minor instead of replacing it with whatever the new preset declared. Meilisearch v1.7 to v1.42 used to swap engines in one step and hard-fail because the older data dir won't load under a newer binary. Rolling-strategy services (mailpit, rustfs, gotenberg) still pick up the preset image as before via the existingtrack_latestblock.
Registry and update detection
- Docker Hub pagination followed.
dockerHubResponse.Nextwas previously read but never followed; long-tail repos (postgres, mysql) lost newer tags past page 1. Capped at 20 pages and 5000 tags so a pathological response can't drive unbounded traffic. - Distinct error classes. New
*AuthRequiredErrand*NotFoundErr. 401/403 means auth needed, 404 means repo doesn't exist, 429/5xx means unreachable. All four collapse to "no update info" viaisQuietRegistryErrbut stay distinguishable. - Token and tag-list calls have separate timeout budgets (15s each). A slow GHCR token endpoint can no longer starve the subsequent tag-list request.
- Concurrent cache writes safe. In-process
cacheMuplus an inline singleflight collapses concurrentListTagscalls; cache writes go to a temp file andos.Renameatomically. Cache write failures emit a rate-limitedlog.Printfinstead of disappearing into a discarded error. - Response body capped at 5 MB and tag list at 5000 entries so a malicious or misconfigured registry can't OOM the process.
- Digest comparisons normalised (lowercase + trim) in the
alreadyOnDigesthelper so registries returning differently-cased digests don't cause a permanent "update available" badge.
Watcher and worktree handling
cleanupWorktreeVhostsno longer triggerscomposer installand the JS install on every surviving worktree. When one worktree was removed, the cleanup pass that re-generated vhosts for the survivors also calledEnsureWorktreeDepson each one, which kicked off a fullcomposer installplus the JS package-manager install. The rename and add paths handle deps viasyncWorktree; doing it from cleanup burned cycles for no benefit..envAPP_URLrewrite is skipped when the value is already correct.rewriteAppURLcompares the new bytes against the file before writing, so a no-op worktree scan no longer bumps.env's mtime. Dev-side watchers (vite, IDE indexers, opcachefile_update_protection) used to react to every scan even when the URL hadn't changed.- lerd-watcher no longer re-runs
composer installand the JS package manager on every restart.scanWorktreesinvokesEnsureWorktreeDepsfor each worktree on startup, which previously calledInstallDependenciesunconditionally; composer's "Nothing to install" path still fires the post-autoload-dump scripts (Laravelpackage:discover, Filament asset publish, etc.), accumulating several seconds of churn per worktree per restart.InstallDependenciesnow consults each manager's install marker (vendor/composer/installed.jsonfor composer;.modules.yaml/.package-lock.json/.yarn/install-state.gz/.bun-tagdepending on the JS lockfile) and skips when the marker is at-or-newer than the lockfile. go test ./...no longer rewrites the user's PHP-FPM quadlets and daemon-reloads systemd, which used to cascade workers into start-limit-hit.internal/php/versions.go::ListInstalled()had a self-heal step that, when called from a test withXDG_CONFIG_HOMEredirected to a temp dir, looked at the user's real running FPM containers, decided their quadlets were "missing", and wrote fresh quadlets plus ransystemd --user daemon-reloadagainst the real session. Each reload triggered a quadlet-generator pass which restarted the FPM unit; with workersBindsTo=lerd-php8X-fpm, that cascade rapidly trippedStartLimitBurstand parked every worker infailed.ListInstalled()is now read-only.
macOS parity
- macOS parity for v1.19 features that were Linux-only. Worker self-heal, the dashboard's failed-worker banner, and the
workers_healthMCP tool see real failed-unit state on macOS now.siteinfo.AllUnitStateswas returning an empty map on darwin, soworkerheal.Detectnever fired. TheUnitLifecycleinterface gainedAllUnitStates(), thedarwinServiceManagerwalks~/Library/LaunchAgents/lerd-*.plistto populate it, andsiteinfo/unitcache_darwin.goplugs the launchd walker in via a newallUnitStatesFnhook. The "last error" excerpt for failed workers also works on macOS now via a platform-splitreadLastErrorthat tails~/Library/Logs/lerd/<unit>.loginstead of relying onjournalctl. lerd lan:exposeno longer fails on macOS when DNS is enabled. The DNS-forwarder install path calledsystemctldirectly to write the unit, enable it, anddaemon-reload, every one of those returned ENOENT on darwin. Routed throughservices.Mgr.WriteServiceUnit/Enable/DaemonReload/RemoveServiceUnitso the same systemd-format unit content renders to a launchd plist on macOS viaparseServiceUnit, and the Linux path is unchanged.- Worktree auto-install no longer skips pnpm / yarn / bun on Apple Silicon. The lerd-watcher daemon inherits launchd's restricted PATH (
/usr/bin:/bin:/usr/sbin:/sbin), so the bareexec.LookPath("pnpm")returned ENOENT and the install step failed for any non-npm project. NewlookJSBinhelper falls back to/opt/homebrew/binand/usr/local/binon darwin, mirroringpodman.PodmanBin(). - Container resource stats on macOS. The dashboard's Resources card was empty on darwin because the bare
exec.Command("podman", ...)couldn't find podman under launchd's restricted PATH. Now usespodman.PodmanBin()like the rest of the project. - Tinker tab works on Laravel projects with non-trivial vendor trees. PHP CLI's 128 M
memory_limitdefault was exhausted byClassAliasAutoloaderduring boot (it requires the full composer class map upfront). Tinker's podman exec now passes-d memory_limit=512M. Also setsPSYSH_TRUST_PROJECT=1to silence PsySH's non-interactive "Restricted Mode" warning that otherwise broke simple expressions likeecho env('APP_URL');. - PostGIS preset on Apple Silicon uses the upstream image instead of the imresamu fork. The official
postgis/postgismanifest publishes only amd64 entries; on darwin a newpodman.PlatformPodmanArgshook injectsPodmanArgs=--platform=linux/amd64into the rendered quadlet so podman pulls the amd64 manifest and Podman Machine runs the container under qemu user-mode (or Rosetta when the VM has it wired). Hooked centrally atWriteQuadletDiffso cli, UI, MCP, and install all emit byte-identical units. Existing data dirs initialised under the imresamu fork open cleanly on the upstream image; users may want to runALTER EXTENSION postgis UPDATEonce after the swap. Pinning a multi-arch fork explicitly in~/.config/lerd/config.yamlbypasses the platform pin entirely.
Other
lerd-tray.servicecaps its restart loop at 3 failures per 60 seconds. Two unrecoverable failure modes (missing GTK runtime, no graphical session under launchd's bootstrap context) used to spin the unit throughRestartSec=2 Restart=on-failureindefinitely; the cap stops the churn while leaving normal transient failures recoverable.- DNS sudoers rules use wildcards in command arguments, breaking install on strict-sudo distros (#269, #272). Already shipped in v1.18.1, included here for completeness across minor branches.
Security
- Bumped npm dev dependencies to clear five medium-severity Dependabot advisories (#277).
internal/ui/webupgraded vite 5 to 7.1,@sveltejs/vite-plugin-svelte4 to 6.2, and vitest 2 to 3.2, which resolves vite to 7.3.2 (path traversal in.maphandling) and esbuild to 0.27.7 (dev-server SOP).docs/package.jsonkeeps vitepress at 1.6.4 and uses npmoverridesto pull vite ^6.4.2, esbuild ^0.25.0, and postcss ^8.5.10 (XSS in CSS stringify) into vitepress's transitive tree. Both manifests now report zero vulnerabilities undernpm audit.
Removed
service_removeandservice_updateMCP tools removed as standalone entries; folded intoservice_controlaction enum (which now also acceptsmigrate,rollback,restart,reinstall).- MySQL 5.6 alternate removed from the preset list. 5.7 and 8.0 remain pickable; 8.4 is the new canonical install.
[1.18.1] — 2026-04-29
Fixed
- DNS sudoers rules use wildcards in command arguments, breaking install on strict-sudo distros (#269, #272). Ubuntu 26.04 LTS made
sudo-rs(the memory-safe Rust rewrite of sudo) the default; sudo-rs's parser rejects wildcards in command arguments outright. The same pattern is rejected by upstream C sudo from 1.9.16 onward, which ships on Fedora 41+, Arch / CachyOS, openSUSE Tumbleweed, and NixOS unstable. Thecp /tmp/lerd-sudo-* /etc/...andresolvectl <verb> *rules lerd wrote to/etc/sudoers.d/lerdnever matched on those parsers, so every DNS reconfigure fell through to the password-prompt path and emitted parse errors visibly duringlerd install("wildcards are not allowed in command arguments"). Fixed by piping content throughsudo tee <fully-qualified-path>instead of staging in/tmpand copying, and by dropping the trailing*from the resolvectl line. The Darwin path got the same treatment so future Apple-bundled sudo updates don't surface the same break. Existing installs heal automatically on the nextlerd install: one password prompt to migrate, then the new rules grant passwordless operation for every subsequent DNS reconfigure. Verified end-to-end on a fresh Ubuntu 26.04 LTS VM running sudo-rs 0.2.13.
[1.18.0] — 2026-04-25
Added
- FrankenPHP runtime (#229). Per-site
dunglas/frankenphpcontainer as an alternative to the shared PHP-FPM image. Laravel and Symfony adapters;lerd runtime frankenphpCLI andsite_runtimeMCP tool to switch; optional worker mode (Laravel Octane, Symfony's FrankenPHP adapter with--watch). Runtime badge shown in both the Web UI and TUI. Paused sites stop/start their per-site container alongside FPM. - Dual-stack IPv4 + IPv6 networking (#230, #247). The lerd podman bridge is created with both subnets (
fd00:1e7d::/64for v6). Nginx vhosts listen on[::], dnsmasq answers AAAA for.test, and every managedPublishPortgets paired with a[::1]bind. Existing v4-only networks auto-migrate on the nextlerd install: containers stop, the network is recreated, previous DNS servers are restored, and containers restart. Hosts without a usable IPv6 address (no non-loopback, non-link-local v6 on any interface) are detected via/proc/net/if_inet6+ thedisable_ipv6sysctl plus a throw-away aardvark probe, and the network is created (or recreated) v4-only. A marker file prevents re-entering the migration loop. See architecture and troubleshooting. --no-ipv6flag andLERD_DISABLE_IPV6=1onlerd install(#251). Force a v4-onlylerdnetwork on dual-stack-capable hosts without touching the host networking stack. Reuses the existing~/.local/share/lerd/ipv6-probe-failed-lerdmarker, soEnsureNetworkhonors the opt-out on every path. Re-enable by deleting the marker and rerunninglerd install.- Three new service presets:
memcached,rabbitmq,elasticsearch(#252). Standard preset convention (lerd service preset <name>). Therabbitmqpreset exposes the management UI athttp://localhost:15672;elasticsearchbinds127.0.0.1:9200so the bundledelasticvuepreset becomes a one-click install on top. - Streaming preset install in the Web UI (#257).
POST /api/services/presets/{name}returnsapplication/x-ndjsonwith events forinstalling_config,starting_deps,pulling_image,starting_unit,waiting_ready, anddone. The image pull is now explicit and happens beforeStartUnit, so the formerly invisible on-demand pull surfaces as liveCopying blob …feedback. The Add button's label tracks the active phase ("Pulling image…", "Starting elasticsearch…", "Waiting for ready…") instead of one opaque "Adding…". The CLI (lerd service preset <name>) and the MCPservice_preset_installtool keep their existing synchronous behavior. - Offline landing page for the installed PWA (#258). A service worker ships with the dashboard and falls back to a dedicated offline page whenever
lerd-uiis unreachable, including the whole-stacklerd quitcase. The page showslerd startwith a copy button and the lerd logo, probes/api/statusevery five seconds, and auto-reloads the dashboard the moment the backend returns./api/*is deliberately not intercepted so the WebSocket and every mutating call keep their normal error semantics. Cache name is versioned with the lerd build so every update invalidates the previous shell cache cleanly. - Service version label across every surface (#246).
lerd service list,lerd status, the Web UI service list and detail header, and the TUI services pane now show the version alongside each built-in, preset, and custom service (e.g.mysql v8.0,redis v7,postgres v16,meilisearch v1.7). Derived from the installed quadlet'sImage=tag viapodman.ServiceVersionLabel, which strips distro/variant suffixes (-alpine,-slim,-3.5), keeps leadingv, and passes rolling tags (latest,main) through verbatim. - Restart button in the Web UI service detail (#246). Built-in and custom services now expose a Restart action alongside Start/Stop, matching the site container row.
POST /api/services/{name}/restartwrapspodman.RestartUnitand clears the paused flag on success. Workers (queue, schedule, horizon, reverb, stripe, site-scoped custom workers) are intentionally excluded. setupMCP tool (#240). Runs the framework'sDefault: truebootstrap commands (Laravel:storage:link+migrate; Symfony:doctrine:migrations:migratewhendoctrine-migrations-bundleis installed). Agents call it afterenv_setupon new or cloned projects; idempotent, no prompts. The interactivelerd setupCLI is unchanged.- Uninstall teardown prompts (#235).
lerd uninstallnow prompts independently for:- Remove MCP integration (global skills + per-site
.claude/.cursor/.junie/.mcp.jsonentries, preserves other MCP servers in shared files). - Uninstall mkcert CA from system trust stores.
- Purge lerd-built container images (
lerd-php*-fpm:local,lerd-custom-*:local,lerd-dnsmasq:local; upstream pulls like mysql/redis are deliberately kept, data lives in host bind mounts not in the images).--forceanswers yes to all.
- Remove MCP integration (global skills + per-site
lerd installrefreshes MCP skills and heals Claude Code registration (#235, #240). Global skill files (~/.claude/skills/lerd/,~/.cursor/rules/lerd.mdc,~/.junie/guidelines.md) and every opted-in site's per-project copies are re-written on install to match the new binary; previously this only ran onlerd update. If Claude Code's user-scope MCP config has lost the lerd entry, install also re-adds it viaclaude mcp add.- Stale-site auto-cleanup covers non-parked sites (#239). The 30 second watcher sweep now removes any registered site whose directory has been deleted, not only those under
parked_directories. Publishes asiteseventbus event so the dashboard reflects the removal without a manual refresh.
Changed
- BREAKING — slimmer MCP tool manifest (#232). The
tools/listresponse merged action pairs (queue_start+queue_stop→queue(action: ...),service_start+service_stop→service_control(action: ...), and similar) and trimmed long descriptions. AI sessions started against the old tool names must be restarted. The new names are reflected in the injected SKILL.md and indocs/features/mcp.md. project_newrunscomposer installafter scaffolding (#240). Thecreate-project --no-installscaffold is chased bycomposer installinside the FPM container, so the returned project has a populatedvendor/ready forenv_setup+setup.env_setupauto-createsdatabase/database.sqlitenon-interactively (#240). Laravel's defaultDB_CONNECTION=sqlitetriggered an interactive prompt inlerd envthat MCP/script callers silently skipped, leaving the sqlite file uncreated and the first request 500'ing. Non-interactive callers now default to sqlite, persist the choice to.lerd.yaml, and run the existing file-creation block. Calldb_setto switch to mysql/postgres afterwards.- Per-session MCP token cost reduced (#236, #237).
tools/listtrimmed ~14% (20 KB → 17 KB), injectedSKILL.mdtrimmed from 44 KB → 40 KB by collapsing redundant single-tool workflow recipes. Descriptions are preserved where weaker local LLMs rely on them (sitefields,pathdefaulting, enum-valued descriptions). - SKILL.md bootstrap workflows rewritten (#240). Replaced the per-framework
artisan migrate/console doctrine:migrations:migratefork with a framework-agnostic sequence: new project =project_new → site_link → env_setup → setup, cloned project =site_link → composer install → env_setup → setup. Debug-500 flow callssetup()for pending migrations. - Install flow starts per-site containers and stripe workers in the correct phase (#234).
lerd installnow starts per-site custom containers and FrankenPHP runtimes after service containers, and stripe listeners fire in the worker phase instead of duringrestoreSiteInfrastructure(no more "stripe starts before FPM" out-of-order).
Fixed
- Aardvark-dns drift after network recreation (#234, #240). When a network is rm'd and recreated with the same name, netavark can preserve the old listen-ips header in aardvark's runtime config, stalling every container DNS lookup ~5 seconds while glibc waits for the non-listening gateway to time out.
EnsureNetworknow detects the drift (viaAardvarkNetworkDrifted) and triggers a recreate; both the dual-stack migration path andlerd uninstall's network teardown wipe$XDG_RUNTIME_DIR/containers/networks/aardvark-dns/<name>betweenrmandcreateso the condition can't re-occur. - Custom container sites not started after
lerd install(#234).install.gonow callsstartPerSiteContainersafterstartRestoredServices, solerd-custom-<site>units come up alongside FPM and global services. Previously they sat enabled-but-stopped until the user ranlerd start. - Stripe listeners started before FPM and nginx were up (#234).
restoreSiteInfrastructurecalledStripeStartForSitesynchronously, unlike other workers which write their unit file and deferStartto the worker phase. NewwriteStripeUnit/StripeRestoreUnitsplit so the start fires instartRestoredServices's worker phase, matching queue/schedule/reverb ordering. lerd sharecollapsed https asset URLs on LAN (#231). HTTPS sites sharing on LAN had asset URLs stripped back to HTTP by the nginx rewrite; the collapse now only fires for http assets on https pages.
Docs
- New "Runtime" section in the commands reference covers
lerd runtime fpm|frankenphpand its--worker/--no-workerflags. - Laravel and Symfony getting-started pages mention FrankenPHP / Octane / Symfony Runtime as optional alternatives to the shared PHP-FPM stack.
- Herd comparison table gains a FrankenPHP / Octane row (Lerd: built-in, free; Herd: Pro-only).
- Architecture dual-stack section and the remote-development "Security caveats" list note v4-only firewall rules bypass and globally-routable v6 SLAAC LAN reach.
- Landing page: two-column hero for MCP + Rootless Podman, new Framework store and Polyglot sites cards, trimmed copy to a 4-row max; hero text scaled down to fit "Local PHP development for Linux" on one line.
- README feature list mentions FrankenPHP; MCP example updated to the post-1.18
site_link → composer install → env_setup → setupsequence; tool count corrected to ~50 after the #232 manifest slim. - New troubleshooting entry for the aardvark-dns drift case (symptoms, cause, manual verification via the aardvark config file).
- Uninstall instructions now cover the three new teardown prompts and
--forcesemantics. - Lifecycle reference documents the stale-site auto-cleanup (fsnotify fast path + 30s sweep + eventbus refresh).
docs/features/mcp.mdtool table addssetupand updates example interactions to the four-step bootstrap sequence.- Getting-started guides (laravel / symfony / wordpress) mention
setupin the AI-assistant tip.
CI
- Skip docs deploy and brew tap upload on pre-release tags (#233). The docs site and the Homebrew tap now track stable tags only; beta and rc tags still build binaries but don't publish.
- Scheduled
check-upstream-phpworkflow now actually triggers a base-image rebuild (#256). The dispatch step ran with the default restrictedGITHUB_TOKENsocreateWorkflowDispatchreturned403 Resource not accessible by integration, and the digest cache was saved before the failing trigger job, advancing the cached digests without an actual rebuild. Jobs are merged,permissions: actions: writeis declared on the job, and the cache save is gated on dispatch success. On failure the prior cache is preserved so the next cron retries.
[1.17.1] — 2026-04-20
Fixed
- Services not started after fresh macOS install.
ensureServiceQuadleton macOS calledWriteContainerUnitFnwhich only writes the launchd plist, not a.containerfile inQuadletDir.startRestoredServiceslater reads that directory viaquadletImage()to decide what to pre-pull; with no file it returns empty and skips the pull, leavingpodman runto auto-pull mysql/postgres/etc. on a brand-new Podman Machine where those pulls often fail or time out. Switched toWriteQuadletDiff, which writes both the.containerfile and the launchd plist viaAfterQuadletWriteFn, so pre-pull works on first install. - PHP FPM images silently missed on first install.
ensureFPMQuadletTowrote the launchd plist only after a successful image build; a failed build left the PHP version unregistered, invisible toensureImages(), and never retried. The plist is now written before the build so the version shows up inlerd status(asimage missing) andlerd startrebuilds it on the next run.lerd install's autostart block also re-runsensureImages()before starting FPM containers so transient build failures heal automatically. - Image pulls routed through lerd-dns on macOS install.
ensureImages()ran afterConfigureResolver(), so every registry pull (nginx, DNS, services, FPM) went through the.testoverride. Moved the call before the DNS block so pulls use the clean system resolver. .containerfile missing for service and custom-container units on macOS. All call sites (FPM, custom services, custom containers, UI server, MCP server) usedWriteContainerUnitFn, which on macOS writes only the launchd plist.quadletImage()then had no file to read, so the pre-pull step was skipped and containers failed on first start. Every site now callsWriteQuadletDiff, which writes both artifacts.- Certificates reissued on every
lerd setup.IssueCertre-ran mkcert even when the cert and key were already present. Now skips when both files exist. - Shims broken under Homebrew installs. php/composer/laravel shims hardcoded
~/.local/bin/lerdas the target binary, so they failed for Homebrew installs at/opt/homebrew/bin/lerd. Shims now resolve the running binary withos.Executable()and use whichever path ranlerd install. - PHP commands failing on fresh installs because
.envservices weren't running.ensureServicesForCwdonly acted on paused sites, so mysql/postgres/etc. referenced in a site's.envstayed down and migrations failed with connection errors. It now starts any referenced service that isn't running, silently, regardless of pause state. - Dashboard preset install left services stopped.
InstallPresetByNameonly wrote the quadlet without starting the container, so services added from the Web UI (mongodb and others) sat idle after install. The UI now starts dependencies and then the service itself, matching the dashboard's Start button. lerd shareURL unreachable while a VPN is active.detectPrimaryLANIPreturned the VPN tunnel address (utun*/tun*) instead of the physical LAN interface, so the shared URL worked on the host but nothing else on the LAN could reach it. The detector now validates which interface the routing-table probe selected and falls back to scanning physical interfaces, skippingutun,tun,tap,wg, and container bridges.- fnm install fails on ARM Linux. The installer only fetched
fnm-linux.zip, which is x86_64-only; arm64 machines needfnm-arm64.zip. Arch detection now picks the correct archive, matching the existing logic used for mkcert. - Go test runs hang for the full timeout.
installCompletioncalledos.Executable()during tests; the resulting path pointed at the test binary, which then re-invoked itself withcompletion bashand hung until CI's 10-minute timeout. The installer now skips when the executable name ends in.test.
[1.17.0] — 2026-04-20
Added
- Nginx per-site overrides (#225). User snippets dropped in
~/.local/share/lerd/nginx/custom.d/{domain}.confnow survive every vhost regeneration and everylerd update. Each generated server block ends with anincludethat pulls that file in, and lerd never writes intocustom.d/so your edits stay put. Fixes #223. - X-Forwarded- propagation into PHP* (#225). Generated vhosts now set
HTTP_HOST,SERVER_NAME,HTTP_X_FORWARDED_HOST,HTTP_X_FORWARDED_PROTO,HTTP_X_FORWARDED_PORT,HTTP_X_REAL_IP, andHTTP_X_FORWARDED_FORvia two http-levelmapblocks ($real_forwarded_host,$real_forwarded_proto) declared once in a newconf.d/_forwarded.conf. Direct browser access is unchanged because the maps fall back to$hostand$scheme; tunnels likelerd share, ngrok, and cloudflared now produce correct absolute URLs out of the box, without any app-sidetrust_proxiesconfig. Fixes #224. - Global AI skill docs refreshed on
lerd update(#222).mcp:enable-globalnow also writes user-scopeSKILL.md, cursor rules, and junie guidelines so AI assistants know about the current lerd MCP tools.lerd updaterewrites those three files from the new binary whenever global MCP is enabled, keeping them aligned with any added or renamed tools. The gate detects both Claude user-scope registration and the lerd-owned marker files, so users without Claude Code installed are still covered. - TUI responsive layout, scrollbars, and color refresh (#217). Below 100 columns the dashboard stacks into a narrow layout: list pane (40%) above detail (60%),
vtoggles between sites and services,tabcycles only through the active list and detail. Sites, services, and the site detail pane gained a scrollbar; the log pane is scrollable with{and}and its header shows the current offset. Colors were rebalanced to match the web UI palette: emerald for running, violet for accents, amber for paused, red for failing.
Fixed
- Country-code TLDs incorrectly encoded in auto-generated site names (#221).
SiteNameAndDomainused a curated TLD list that missed most ccTLDs, so a directory namedastrolov.roproducedastrolov-ro.testinstead ofastrolov.test. Replaced with a regex matching any trailing two-letter suffix, covering every ISO 3166 code without a maintenance list. Multi-letter gTLDs (.com,.net,.info,.dev,.app,.ltd, and friends) stay on the curated list soapp.v2andbackup.oldsurvive unchanged. - Invalid
AWS_BUCKETnames on rustfs sites (#220). The framework template wrote the underscored database handle, which rustfs rejects.envMap["AWS_BUCKET"]now flows throughs3BucketNameon every run so stale invalid values auto-heal, and a newtemplate placeholder resolves to the S3-safe form. Existing sites with a broken bucket name are repaired on the nextlerd env. - Auto-stop skipped Podman services on macOS after all sites were paused (#216). Services using Podman's
--restart=alwayspolicy sit with a launchd plist in the "not running, never exited" state;UnitStatusfell through toContainerRunning, but a transientpodman inspectfailure (common under VM socket contention) returned "failed" and auto-stop silently skipped the service.ContainerRunningis now the authoritative check, withUnitStatuskept as a fallback when the container is not found. Postgres and meilisearch now stop as expected.
Changed
- Docs workflow deploys only on tag release (#219). The GitHub Pages deploy now triggers on
v*tag pushes instead of every push to main, so the published site tracks tagged versions and doesn't republish on internal-only merges.
[1.16.0] — 2026-04-17
Added
lerd tui— terminal dashboard. A btop-style, full-screen dashboard for sites, services, and workers, with near parity to the Web UI and System Tray. Built on the same bubbletea / lipgloss stack already used forlerd manand the samesiteinfo/podman.Cache/ eventbus plumbing that driveslerd-ui, so both surfaces see identical live state.- Layout: Sites + Services stacked in the left column, a full-height Site detail pane on the right, and a toggleable log tail below (
l). Header shows DNS / nginx / FPM status plus anupdate: vX.Y.Zbanner when a newer release is cached. - Site detail: primary domain header, internal name, disk path, full domains list (add with
a, rename withe, remove withx), services-used with live state, workers (toggle withspace), git worktrees, HTTPS toggle, LAN share toggle (showshttp://<lan-ip>:<port>when on), PHP and Node version pickers (open withspace, commit withenter, backed bylerd isolate/lerd isolate:node). - Services pane includes site-owned workers (
queue-<site>,schedule-<site>,horizon-<site>,reverb-<site>, custom framework workers), routed throughlerd queue start/stop,lerd worker start/stop <name>, etc.topens an interactive shell in the focused container (FPM or custom for sites, the service container for services, the owning site's FPM for workers). - Filter + sort:
/to filter sites / services by name (sites also match domains and framework label),oto cycle sort (sites: name · status · framework; services: name · status · usage).vhides the services pane. - Log sources:
[/]cycle through FPM / custom container, every worker journal (journalctl --user -u lerd-<kind>-<site>), and every file matched by the framework'sfw.Logsglobs (Laravel:storage/logs/*.log). Logs pane takes at least half the window and has a right-edge scrollbar. - In-pane overlays:
Sswaps the detail pane for global Settings (LAN expose, autostart on login, Xdebug per PHP version) and moves focus into it;?swaps it for a scrollable Keybindings reference.escreturns to Site detail. - Updates live by subscribing to the in-process eventbus and re-querying every 2 s so changes made from another terminal surface within a couple of seconds.
- Layout: Sites + Services stacked in the left column, a full-height Site detail pane on the right, and a toggleable log tail below (
Selectable Xdebug mode per PHP version (#205).
lerd xdebug onnow accepts--mode(debug,coverage,profile,trace,develop,gcstats, or comma combos likedebug,coverage); previously mode was hardcoded todebug. The dashboard gains a mode dropdown next to the Xdebug toggle and a clickable Xdebug chip on each site row. The MCPxdebug_ontool accepts amodeargument. Toggle orchestration (validate → persist → write ini → restart FPM) is extracted into a newxdebugopspackage; the three surfaces (CLI, UI, MCP) are now thin wrappers. Legacy configs with no saved mode resolve todebugso existing setups are unaffected.Adaptive Podman Machine memory on macOS (#206). The VM memory target now scales with host RAM instead of a fixed 4 GB floor: 3 GB on machines with ≤8 GB, 4 GB on 9–31 GB, 6 GB on 32 GB+. Detection uses
sysctl hw.memsize; falls back to 4 GB when detection fails. On 8 GB MacBooks lerd prints a note with the manual override command (podman machine set --memory 4096) so the tradeoff is visible.lerd quitstops the Podman Machine VM on macOS. After all containers, workers, the Web UI, watcher, and tray are shut down,lerd quitcallspodman machine stopon any running machine.lerd startalready starts the machine, so quit and start are now fully symmetric. No change on Linux where Podman runs natively without a VM.
Fixed
Worker log tabs stuck on "connecting..." in the dashboard (#210). Two separate bugs combined. First, silent units (no output until an event fires) never triggered the first body write, so Go's
http.ResponseWriternever sent the HTTP 200 +text/event-streamheaders and the browser'sEventSourcestayed inCONNECTING. Fixed by flushing a: connectedSSE comment immediately after writing headers. Second, switching tabs opened a newEventSourcewithout closing the previous one; after a few clicks all six browser HTTP/1.1 connections were consumed and new streams queued indefinitely. Fixed by closing every non-active worker log stream before opening the new one.Git worktrees running stale code from the main checkout (#209).
vendor/andnode_modules/in a new worktree were symlinks back to the main repo. PHP resolves__DIR__through symlinks, sovendor/autoload.phpreported the main repo path and Composer'sClassLoaderloaded every class frommain's source tree, silently ignoring any divergedapp/orsrc/files in the worktree. The symlinks are now replaced with real copies seeded from the main repo using reflink-aware helpers (cp -a --reflink=autoon Linux btrfs/xfs,cp -Rcon macOS APFS, plain Go walk elsewhere), followed bycomposer installandnpm cito reconcile against the worktree's own lockfiles.
[1.15.1] — 2026-04-16
Fixed
lerd.localhost504 on rootless Linux. The dashboard vhost reverse-proxied tohost.containers.internal:7073, which on rootless podman setups where netavark resolves that name to169.254.1.2but doesn't wire up a bridge alias or DNAT for it routed packets into a dead end, and the proxy hop timed out after 60 seconds.lerd-uinow also binds a unix domain socket at~/.local/share/lerd/run/lerd-ui.sock, thelerd-nginxquadlet bind-mounts that path read-write, and the Linux vhostproxy_passes throughhttp://unix:...instead of TCP. Unix sockets depend on filesystem access, not container networking, so the dashboard no longer breaks when your netavark/pasta/rootless stack shifts between versions or your host changes networks. macOS keeps the TCP path viahost.containers.internal:7073because unix sockets don't traverse the podman-machine virtio-fs boundary as functional sockets, and gvproxy reliably forwards that upstream there.- Xdebug times out silently on rootless Linux (same class of bug as #186). The 1.13.1 fix replaced a hardcoded
169.254.1.2with a dynamicgetent hosts host.containers.internalprobe but still trusted whatever netavark returned without checking it actually routed. On setups where netavark gives the same 169.254.1.2 back, the fix is a no-op and Xdebug fails withTime-out connecting to debugging clientexactly as before.DetectHostGatewayIPnow runs a real reachability probe: from insidelerd-nginx, TCP-connect to lerd-ui:7073 for each candidate (getent's answer, the host's primary LAN IP, slirp4netns's10.0.2.2) and use the first that opens. If nothing works, fall back to the legacy constant and surface the failure inlerd doctorunder a new[Container → Host connectivity]section so users get a concrete diagnosis instead of silent retries. - Xdebug breaks when the laptop changes networks. A probe at
lerd startpins a LAN IP into/etc/hosts, which goes stale the moment you move from home wifi to a coffee shop or rotate DHCP. Newlerd-watchergoroutine reprobes the host gateway on LAN change and rewrites the shared/etc/hostsin place, so PHP-FPM containers pick up the new address on the nextgetaddrinfocall without a container restart. Steady-state cost is near zero: a singlenet.Dial("udp4", "1.1.1.1:80")routing-table lookup per 30 s tick (never sends a packet, just reads the kernel's source IP for the default route). The expensive probe only runs when the primary LAN IP actually changes. Matters most on macOS where eachpodman execthrough gvproxy costs 300 ms to 1 s, so a naive probe-every-tick design would burn 1-3 % of a core continuously on battery. - Podman auto-creates a directory at missing bind-mount source paths. When the FPM container starts before an ini file has been written, podman satisfies the
Volume=clause by creating the source as a directory, and the next write against that path either silently no-ops (EnsureUserInireturned early on theos.Statsuccess without checkingIsDir) or fails withis a directory(WriteXdebugIni, the inline hosts-file pre-create). Fix:EnsureXdebugIniandWriteXdebugInidetect a stale directory and remove it before writing;EnsureUserInigot the same self-heal; the inline hosts pre-create was extracted intoensureFPMHostsFilewhich normalises stale-directory, missing, and regular-file states into "regular file present";WriteContainerHostsandwriteBrowserHostsnowMkdirAlltheir parent instead of assuming the data dir already exists. Scanned everyVolume=source on the embedded FPM, nginx, and service quadlets to confirm these three file sources were the remaining ones needing pre-creation (directory-typed mounts likedata/*andconf.dare safe because podman creating them is the right behaviour). - Dashboard shows containers as still running after
lerd stop. The in-processAfterUnitChangehook refreshedpodman.Cachebefore broadcasting, but the/api/internal/notifyendpoint that CLI and MCP processes use to signal unit lifecycle changes only invalidated thesiteinfocache and published events without refreshing the container cache. Site/FPM running flags read frompodman.Cache, so afterlerd stopthe browser kept reporting everything as up until the 15-60 s background poller next ticked. The notify handler now also callspodman.Cache.PollNow()in a goroutine so state flips within a second of the CLI exiting while the handler still returns under the 500 ms POST timeout.
[1.15.0] — 2026-04-16
Added
- Per-project custom container support (#198). Non-PHP sites (Node.js, Python, Go, Ruby, etc.) can define a
Containerfile.lerdand acontainer:section in.lerd.yaml. Lerd builds a dedicated image, runs it as a named container, and nginx reverse-proxies to it. Full lifecycle:lerd linkbuilds and starts,lerd unlinkstops and cleans up (prompts to remove the image),lerd secure/lerd unsecuretoggle HTTPS,lerd pause/lerd unpausestop and start the container,lerd restartrestarts without rebuilding,lerd rebuildforces a fresh image build. Workers defined incustom_workersexec into the container. Services are reachable by name (lerd-mysql,lerd-redis, etc.) on the shared Podman network. lerd restartcommand. Restarts the container for any site type: the per-project custom container for custom sites, or the shared PHP-FPM container for PHP sites. Also available assite_restartMCP tool and in the dashboard (restart icon in the site header).lerd rebuildcommand. Rebuilds the custom container image from the Containerfile and restarts the container. Also available assite_rebuildMCP tool andPOST /api/sites/{domain}/rebuildin the dashboard.lerd initcustom container wizard. When no PHP project is detected (nocomposer.json, no framework) and aContainerfile.lerdexists, the wizard switches to custom container mode and asks for the container port, containerfile path, HTTPS, and services.- Containerfile MD5 caching.
lerd linkskips the image build when the Containerfile hasn't changed since the last build. The hash is stored in~/.local/share/lerd/container-hashes/.lerd rebuildalways forces a fresh build. - Dashboard: custom container UI. Container icon (cube) in the sidebar, base image badge (e.g.
node:22-alpine :3000) instead of the PHP dropdown, "Container" logs tab, restart button, worker toggles forcustom_workers, running/stopped status reflecting the custom container. site_restartandsite_rebuildMCP tools. Skill content updated with custom container architecture,.lerd.yamlreference includingcontainerandcustom_workersfields, setup workflow, and env var configuration guidance.
Fixed
- Watcher overwriting custom container sites. The site file watcher and
siteinfo.enrichVersionsno longer re-detect PHP/Node versions for custom container sites, preventing the empty values from being overwritten with defaults. - Parked watcher re-registering custom containers.
RegisterProjectnow skips sites already registered as custom containers. - Service auto-stop ignoring
.lerd.yaml.CountSitesUsingServiceandsitesUsingServicenow check.lerd.yamlservices list in addition to.envscanning, preventing auto-stop of services used only by custom container sites. - Domain change producing 502.
RegenerateSiteVhostnow uses custom container vhost templates for custom sites instead of PHP templates. lerd install/lerd updateoverwriting custom vhosts. The vhost regeneration during install now branches for custom container sites.lerd start/lerd stoptrying to start/stop workers for ignored sites.registeredFrameworkWorkerUnitsnow skips ignored and paused sites.lerd pause/lerd unpausenot stopping/starting custom containers. Pause now stops the custom container, unpause starts it and restores the proxy vhost.
[1.14.1] — 2026-04-16
Fixed
- Node version dropdown missing from site rows in the dashboard. The 1.14.0
node_managed_by_lerdgate was implemented as an outer<template x-if>wrapping two inner templates (empty-list placeholder and populated<select>). Alpine.js'sx-ifdirective only renders a single child element, so the outer template silently rendered nothing and the Node dropdown disappeared for every site, even on machines where lerd manages Node. Flattened into two sibling templates that each include thenode_managed_by_lerdcondition inline, matching the existing PHP dropdown pattern.
[1.14.0] — 2026-04-16
Added
- Node version management (#191). Lerd now detects whether Node is managed by the system (distro package, nvm, fnm, mise, asdf, volta) or by lerd itself, and adapts the UI and init wizard accordingly. On machines where Node is system-managed, the dashboard shows a "system" badge next to the Node.js sidebar section, hides the per-site Node version dropdown, and the
lerd initwizard omits the Node version input (an existingnode_versionin.lerd.yamlis preserved). The status API gainsnode_managed_by_lerd. Also fixes a UI regression where installing Node from the dashboard could emit a spurious "unknown version" error. - Decoupled
lerd db:*commands (#192).lerd db:import,db:export,db:create, anddb:shellnow work in any project type (NestJS, Next.js, Go, Rails, etc.) without requiring a linked site or PHP-style.env. Resolution chain (first match wins):--serviceflag,.lerd.yaml db:block, framework detect rules, then generic.envinference (DB_CONNECTION/DB_TYPE/TYPEORM_CONNECTION/DATABASE_URL/DB_PORT). Credentials from.envare intentionally ignored, because lerd always connects viapodman execusing the container's fixed admin credentials (postgres/lerdorroot/lerd), so a mismatchedDB_USERNAME=rootagainst a pgsql container no longer fails withrole "root" does not exist.db:shellnow checks whether the target database exists and prompts to create it before opening the shell, instead of dumping a raw psql error.
Changed
- Skip
.envbackup when lerd has already written the file (#193).lerd envused to unconditionally copy.envto.env.before_lerdon first run, which could overwrite a legitimate user backup if lerd had previously rewritten the file. The backup is now skipped when lerd has already written.envin this project, so.env.before_lerdalways reflects the user's pre-lerd state. - Tray improvements (#194). The tray "Open Dashboard" entry now opens the dashboard in the default browser, the update prompt wording is clearer, and "Quit" now stops the full lerd-ui + daemon stack instead of just dismissing the tray.
[1.13.1] — 2026-04-14
Fixed
- Xdebug and inter-site HTTP inside PHP-FPM containers (closes #186). The shared
/etc/hostsbind-mounted into every PHP-FPM container used to hardcode169.254.1.2both forhost.containers.internaland for every linked.testdomain. That address is only a valid host gateway on rootless podman with pasta/netavark/slirp4netns, so Xdebug timed out connecting back to the IDE on other podman configurations. It also routed inter-site HTTP through a fragileFPM → pasta host-loopback → host 127.0.0.1:80 (rootlessport) → lerd-nginxchain that failed on some podman versions and surfaced as 504s during debugging.WriteContainerHostsnow probes the real host gateway by exec-inggetent hosts host.containers.internalinsidelerd-nginx, with a throwaway alpine container on the lerd network as fallback, and the old constant as a final fallback. Two distinct IPs are written:host.containers.internalpoints at the detected host gateway for Xdebug and any host-side tooling, while every.testdomain resolves straight tolerd-nginx's bridge IP so inter-site HTTP travels container-to-container over the lerd network without any pasta hop. Rendering was extracted into a purerenderContainerHostshelper with table-driven unit tests covering empty registries, nginx IP wiring, IP separation regressions, and loopback preservation.
[1.13.0] — 2026-04-14
Added
lerd lan:share/lerd lan:unshare— expose a single site to other devices on the local network at a stablehttp://IP:PORTURL with no client-side DNS setup. A host-level reverse proxy runs in the lerd-ui daemon, rewriting theHostheader so nginx routes to the correct vhost and rewriting absolute URLs (https://domain→http://LAN-IP:port) in HTML, CSS, and JS bodies so assets and redirects work without DNS.Accept-Encoding: identityis forced upstream and gzip is decoded inModifyResponseto keep body rewriting reliable, andLocationheaders are rewritten on redirects. Each site gets a stable port from 9100 onwards (saved insites.yaml, restored on daemon start) that avoids conflicts with Reverb and other services. The CLI prints a compact half-block Unicode QR code after sharing; the dashboard UI adds a LAN toggle next to HTTPS with the URL inline and a fixed-positioned QR tooltip on hover (fixed positioning escapesoverflow-x-autoclipping on parents). QR PNGs are served from/api/lan-qr/{domain}. Closes #179.lerd import sail(aliaslerd sail import) — migrate an existing Laravel Sail project into lerd without manual dump/restore. Detects Docker or Podman Compose, remaps conflicting ports and strips non-data service ports so Sail starts cleanly alongside lerd, waits for the database, auto-detects the Sail DB name (handles the case wherelerd envalready overwroteDB_DATABASE), dumps the DB from the Sail container into lerd's MySQL/PostgreSQL, reads MinIO credentials from the composeenvironmentblock, and mirrors the bucket into RustFS viamc. Tears Sail back down when done (--no-stopkeeps it running).lerd envnow backs up.env→.env.before_lerdon first run andlerd env:restorebrings it back.lerd linkdetectslaravel/sailincomposer.jsonand prompts to run the import before setup, passingDB_DATABASEthrough automatically.- Hardcoded bundled preset files (
preset_files.go) — preset file mounts (phpmyadmin config, etc.) ship inside the Go binary instead of being copied into~/.config/lerd/services/*.yaml, solerdupdates roll out new preset contents on the next service start withoutremove + reinstall. Legacyfiles:entries in user yaml are auto-stripped and re-saved on load. Newlines and NUL bytes in custom service env values are now rejected to close a quadletEnvironment=injection vector. - URL hash routing in the dashboard —
#sites/<domain>,#services/<name>,#system/<section>,#service/<name>(dashboard iframe), and#docsare now deep-linkable with working back/forward navigation.loadSitesauto-select only fires when thesitestab is active and the hash doesn't already claim an iframe view, so refreshing on a sub-page stays put. repeat_familydynamic_env directive — produces N copies of a value aligned with the host list fromdiscover_family, used forPMA_USERS/PMA_PASSWORDSso phpmyadmin can pre-auth against every database in a family.- GitHub star nudge — low-key prompt added to the installer and dashboard.
Fixed
- Service dashboards rendered broken inside the iframe overlay — phpmyadmin lost session cookies across the cross-origin iframe and pgadmin's list-databases XHR dropped cookies after the initial connect. The phpmyadmin preset now rebuilds
cfg['Servers']fromPMA_HOSTS/PMA_USERS/PMA_PASSWORDSwithauth_type=configfor multi-host auto-login and setsCookieSameSite=Noneplus forced HTTPS env so cookies flow inside the iframe. The pgadmin preset setsSESSION_COOKIE_SAMESITE=NoneandSESSION_COOKIE_SECURE=Truefor the same reason. Rustfs now starts with--console-enableand the dashboard URL points at/rustfs/console/so the iframe lands on the web UI instead of the raw S3 XML. The preset picker also hides presets with unmet dependencies (mongo-express disappears until mongo is installed) instead of just disabling the install button. - Lerd-ui spawned terminals died silently when started at boot — when lerd-ui runs as a lingering systemd user service it starts before the compositor exists and inherits an empty graphical environment (no
WAYLAND_DISPLAY/DISPLAY), so any GUI terminal it forked exited immediately. Clicking site Terminal or "Open terminal & update" did nothing with no visible error.graphicalEnv()now pulls the graphical vars fromsystemctl --user show-environmentand probesXDG_RUNTIME_DIRfor awayland-*socket as a last resort, so spawned terminals can always reach the compositor regardless of how lerd-ui itself was launched. Darwin is skipped becauseopen -a Terminalreattaches to the Aqua session on its own. - PHP-FPM subdomain detection —
SERVER_NAMEis now set to$hostin PHP-FPM vhosts so subdomain routing works correctly under nginx. - Mobile dashboard layout broken by the iframe overlay — the dashboard iframe assumed the desktop left rail was always visible and extended full-height, hiding the mobile bottom nav, and dashboard service icons only existed in the desktop rail so mobile users had no way to reach them. The overlay now spans full width on mobile and stops above the nav, the mobile bottom nav gains a scrollable dashboards group with a separator, the nav is pinned to
h-16to match the iframe's reserved offset, the docs sidebar link reuses the iframe trigger, and the bottom nav is flattened so built-in tabs and dashboard services share equal width instead of each group claiming half the bar. lerd manindexednode_moduleswhen walking docs — the docs FS walker now skipsnode_modulesso man-page generation no longer drags vendor directories into the index.- VitePress build broken on
{{.Resources.Memory}}— Vue's compiler parses interpolations even inside backtick code spans, and the leading dot fails JS expression parsing. The token is now wrapped in an explicit<code v-pre>element so Vue skips it.
[1.12.6] — 2026-04-13
Added
- Background container state cache — a single goroutine polls
podman ps -a --filter name=lerd-on a 15 s (focused tab) / 60 s (idle) cadence and serves every hot path (buildStatus, siteinfo,IsActive,/api/sites,/api/services,/api/status) from an in-memory snapshot instead of spawning apodman inspectsubprocess per container per request. Snapshot rebuilds are serialised with aTryLockso concurrent requests share the in-flight build rather than each triggering their own batch. Browser Page Visibility is piped over the WebSocket so the server downshifts cache polling when every tab is hidden. The tray poller drops from 5 s to 30 s. Net effect on macOS: the idle VM no longer burns 30–80 % host CPU from repeatedpodman machine sshround-trips.
Fixed
- UI Stop button hung for a minute on slow-stopping services — stopping Selenium (or any custom service using
supervisord/Chromium) from the web UI would leave the button spinning for 30–60 s while systemctl waited on the container's graceful shutdown. Custom service quadlets now emitStopTimeout=5so podmanSIGKILLs after 5 s, matching the existing--stop-timeout=5behaviour on macOS. The UItoggleServicehandler also wraps the POST in an 8 sAbortControllertimeout and shows "Stopping in background…" on abort, so the button always releases promptly and the WebSocket snapshot push backfills the final state. Existing installs of affected services can pick up the new timeout withlerd service remove <name> && lerd service preset <name>. - Worker entries clobbered on
lerd uninstall→lerd install—WorkerStartForSitecalledSetProjectWorkers(CollectRunningWorkerNames(...)), which fully replaced the.lerd.yamlworkers list on every invocation. Workers started sequentially duringlerd setupoverwrote each other's entries, so after an uninstall/install cycle only the last-started worker survived. Now uses a new additiveAddProjectWorkerhelper.StripeStartForSitealso persists"stripe"after a successful start so it survives the same cycle, andlerd initnow listsstripein the Workers multi-select whenSTRIPE_SECRETis present in the site's.env. - UI worker toggles visually reverted —
AfterUnitChangepublished the snapshot broadcast before the container cache had re-polled, so the first frame after toggling a worker carried the old state and the button appeared to flip back. The hook now callsCache.PollNow()from a goroutine before publishing, so the broadcast always carries fresh data. The activating state is also now treated as running (not failing) for queue/schedule/reverb/horizon and generic framework workers, so the brief startup window no longer flashes a red error indicator. - WebSocket initial snapshot could be stale —
handleWScalled the asyncCache.Refresh()before assembling the first frame, meaning a freshly-opened browser could see container states from beforelerd startran. Replaced with the synchronousCache.PollNow()so the first frame on every new connection reflects current reality, even when multiple tabs reconnect at the same time. - Rustfs bucket not created for Laravel projects —
lerd envonly ran the rustfs bucket creation logic inside the fallbackknownServicesloop. Laravel projects go throughfw.Env.Services, which skipped the branch entirely, so Dusk/Panther sites hit "bucket does not exist" errors on first upload. The bucket create/mc anonymous set publiclogic now runs on the framework service path too, honours an existingAWS_BUCKETvalue in.envinstead of always overwriting with the project slug, and retries up to 3 times (2 s apart) to bridge the window between the host TCP port becoming reachable and themccontainer being able to connect over thelerdnetwork. - Default PHP FPM was auto-stopped when unused —
autoStopUnusedFPMsdidn't exemptcfg.PHP.DefaultVersion, so setting a default (e.g. 8.5) with no site explicitly referencing it would stop the container immediately after start, breakingphp,composer, andlaravel newshims. The helper now mirrorscoreUnits()and always keeps the default version running. - macOS Podman Machine memory resize fired on every start — the
{{.Resources.Memory}}inspect template returns MiB, not bytes, but the comparison assumed bytes. Machines already at 4096 MiB tripped the condition and the CLI stopped + resized the VM on everylerd start. Fixed the unit handling and, while there, lengthened the readiness timeout from 90 s to 120 s with a 3 s grace period, so the post-resize restart no longer races the API socket.ensureDefaultPHPInstalledalso now auto-builds the FPM image + writes the quadlet on the firstlerd startafter switching the configured default PHP version, so users don't have to runlerd php installmanually.
[1.12.5] — 2026-04-13
Fixed
- macOS ARM64 postgres pulled an image with no ARM64 manifest —
platformImageOverridewas applied beforesvcCfg.Imagefrom global config, so the macOS substitute (imresamu/postgis) was silently overwritten bypostgis/postgis:16-3.5-alpineon everyensureServiceQuadletandlerd installrun. The override now runs last and only when the resolved image is the known-bad upstreampostgis/postgis+alpinesuffix, leaving user-pinned custom images untouched. The embedded quadlet fallback also moves frompostgis:16-3.5-alpinetopostgis:16-3.5so fresh Linux installs get an image with an ARM64 manifest. (#175)
[1.12.4] — 2026-04-13
Fixed
pgpassrewrite failed with permission denied — file mounts declared withchown: true(e.g. pgAdmin's/pgpass) get re-owned to a userns-mapped uid by podman's:Uflag. On the next materialize the host process could no longer open the file for writing and surfacedopen …/pgpass: permission denied.MaterializeServiceFilesnow unlinks the existing entry before writing, so a stale userns-owned file is replaced cleanly.
[1.12.3] — 2026-04-13
Fixed
- pgAdmin crash loop and iframe embedding — pgAdmin now ships a mounted
config_local.pythat disablesX-Frame-Options,ENHANCED_COOKIE_PROTECTION, andWTF_CSRF_CHECK_DEFAULT, so it renders inside the inline dashboard overlay with working sessions and preferences. Also fixes a launchd plist XML escaping bug where'and"in env var values were emitted as numeric character references that Apple's plist parser passed through literally, corrupting container env and crash-looping pgAdmin on macOS. Adds a Slonik (elephant) icon for pgAdmin in the dashboard rail. (#171) - UI service remove left family consumers stale — the web UI's remove handler did not call
RegenerateFamilyConsumers, so removing mariadb from the UI left phpMyAdmin'sPMA_HOSTSpointing at the gone host. The UI now matches the CLI behaviour. (#172) - Workers showed as off on macOS while running —
unitStatusFndefaulted to asystemctl-based path that does not exist on macOS, so the UI never reflected running workers. Darwin now overrides it to usepodman.UnitStatus, the same pathlerd statususes. (#170)
[1.12.2] — 2026-04-13
Added
- Inline dashboard iframes — service dashboards (phpMyAdmin, pgAdmin, Mailpit, RustFS, Meilisearch, Mongo Express, Selenium…) now open as a full-width overlay inside the lerd UI instead of a new browser tab. The left icon rail grows a separator followed by one stroke icon per running dashboard-exposing service, and the service detail Dashboard / Open phpMyAdmin / Open pgAdmin buttons route through the same overlay. Clicking any of the main nav icons closes it. An Open-in-new-tab escape hatch remains for dashboards that refuse framing or lose session cookies under third-party partitioning. (#168)
- phpMyAdmin ships
AllowThirdPartyFraming— the phpmyadmin preset now materialises/etc/phpmyadmin/config.user.inc.phpwith$cfg['AllowThirdPartyFraming'] = true;so it renders inside the inline overlay. Existing installs mustlerd service remove phpmyadmin && lerd service preset phpmyadminafter upgrading to pick up the new file mount.
[1.12.1] — 2026-04-13
Added
- macOS release pipeline and Homebrew tap — tagged releases now build darwin amd64/arm64 binaries and publish to the
geodro/homebrew-lerdtap. Install on macOS withbrew tap geodro/lerd && brew install lerd && lerd install.
Fixed
- Short-name pulls failed on Ubuntu — built-in service images (
mysql,redis,postgis,meilisearch,rustfs,mailpit) were stored as short names and failed on distros whose/etc/containers/registries.confhas no unqualified-search registries. All defaults are now fully qualified withdocker.io/, and existing configs are auto-migrated on next load. - Installing a preset from the UI did nothing visible — the
/api/services/presets/endpoint did not publish an eventbus event after a successful install, so the 2-second snapshot cache kept returning the stale services list. The frontend's immediateloadServices()then failed to find the new service, leaving the modal open and the dashboard unchanged. The endpoint now invalidates the cache and broadcasts over WebSocket, so the phpMyAdmin (and any other preset) install flow closes the modal, switches to the Services tab, selects the new service, and starts it.
[1.12.0] — 2026-04-13
Added
- macOS platform support — first-class macOS alongside Linux. Installation, DNS, autostart, start/stop (with Podman Machine), PHP version detection, UI log streaming, and service management are all platform-split. Dedicated macOS CI build and test job.
- macOS service management — workers UI, tray fix, parallel service start, and LAN support on macOS.
- WebSocket live dashboard updates — the dashboard now receives push updates over WebSocket instead of polling, cutting idle request traffic and reflecting site/worker state changes immediately. (#161)
- Scheduled (timer-driven) framework workers — frameworks can declare workers that run on a systemd timer instead of as long-lived processes. Timers are included in
lerd start/lerd stop, surfaced in the UI, and detected via sibling.timerunits in worker status. (#160) - Platform-split UI log streaming — log tailing routes through platform-specific backends (journald on Linux, log(1) on macOS).
- Cross-platform service management, DNS split, and CLI utilities — foundation for multi-OS support: abstracted service layer, platform-specific DNS handling, and shared CLI helpers.
Fixed
- Input validation and credential handling hardened — security audit sweep across CLI entry points and credential handling paths.
- Scheduled-worker lifecycle — orphan
.timerfiles are now skipped and cleaned up, timer-driven workers report as active in the UI, and stopping/tearing down timer-backed workers collapses and cleans up cleanly. - Per-version framework resolution —
schedule,queue,horizon, andreverbshortcuts now resolve the framework definition per-version instead of falling back to a single global definition. - Perpetual service quadlet rewrite on install —
lerd installno longer rewrites service quadlets on every run, which previously dropped local edits and triggered needless restarts. - Skip Laravel installer prompt when already installed —
lerd setupno longer asks to install Laravel when the project is already initialised. - macOS terminal integration —
lerd openand the UI terminal button open Terminal.app silently at the project directory;lerd updatedefers tobrew upgrade lerdon macOS. - macOS DNS sleep/wake repair, tray startup, and install ordering — DNS survives sleep/wake cycles, the tray starts reliably on login, and install ordering avoids race conditions. Sequential install image pulls keep the sudo prompt visible.
- Launchctl kickstart hang on tray restart —
lerd-trayrestart no longer hangs insidelaunchctl kickstarton macOS. - RunParallel keypress goroutine swallowed sudo input — the parallel-run keypress watcher no longer competes with sudo for stdin, so password prompts work again.
- Install linger and sudo prompt UX —
lerd installenables systemd user linger automatically, and the linger sudo prompt renders on its own line for readability. - Default PHP-FPM always starts in
lerd start— the default PHP-FPM unit is always brought up, preventing "no PHP handler" errors on fresh boots. - Linux worker restore, PostGIS migration, and UI request pile-up — hardened worker restoration on Linux, fixed PostGIS database migration, and stopped the UI from piling up in-flight requests.
- Remote CA installed into isolated CAROOT —
lerd setup --remoteinstalls its CA into a dedicated CAROOT so it no longer overwrites the local mkcert root.
Changed
- Platform-split installation — binaries, DNS, autostart, and cleanup routines are now dispatched through a platform interface rather than hardcoded to Linux.
- Platform-split start/stop —
lerd start/lerd stoprun through per-platform implementations, including Podman Machine orchestration on macOS. - Platform-split PHP version detection — PHP version discovery runs through platform-specific probes.
Docs
- macOS in the tagline and install docs — the tagline, install instructions, and per-platform update steps now include macOS. Beta wording and "coming soon" / "Linux-only" phrasing removed throughout.
[1.11.0] — 2026-04-11
Added
- Ptyxis terminal support —
lerd openand the tray menu now detect and launch Ptyxis, the GNOME 47+ terminal emulator. - Link → init → setup flow — after
lerd link, the CLI guides the user throughlerd initandlerd setupwhen the project hasn't been initialised yet. - PHP version suggestion during link — when the project requires a PHP version that isn't installed,
lerd linksuggests installing it. - Favicon field in framework definitions — frameworks can now declare a custom favicon path (e.g.
core/misc/favicon.icofor Drupal) so the dashboard shows the correct icon.
Fixed
- Framework detection for custom frameworks — detection rules now read
composer.jsondirectly and support customdetectrules, fixing detection for frameworks like Drupal, CakePHP, and WordPress. - Worker checks and env setup for custom frameworks — worker
checkrules and env variable setup now work correctly for non-Laravel frameworks. - Favicon detection uses framework public_dir — custom frameworks with non-standard public directories (e.g.
web/for Symfony/Drupal) now have their favicons detected correctly. - 0-byte favicon files skipped — empty favicon placeholder files no longer show as having a favicon in the dashboard.
- Link only writes .lerd.yaml when it already exists — avoids creating an unnecessary config file for projects that don't use one.
Changed
- Site enrichment consolidated into
internal/siteinfo— CLI, MCP, and UI no longer duplicate site enrichment logic. A singleLoadAll(flags)function with flag-based enrichment replaces ~340 lines of duplicated code across three packages. - Link/unlink core logic extracted into
internal/siteops— shared site operations (vhost generation, site naming, linking, unlinking) moved out of the CLI package for reuse by MCP and UI. - Framework detection centralised —
DetectFrameworkForDirand.lerd.yamloperations moved into the config package, eliminating scattered detection logic.
[1.10.1] — 2026-04-10
Fixed
- phpMyAdmin (and other
dynamic_envpresets) connected to wrong host — the Web UI and MCPservice_start/service_addcode paths generated custom service quadlets without resolvingdynamic_envdirectives, soPMA_HOSTSwas never set and phpMyAdmin fell back to its default hostdb. All three paths now delegate toserviceops.EnsureCustomServiceQuadletwhich handlesdynamic_envresolution and file materialisation.
[1.10.0] — 2026-04-10
Added
- Framework definition store — community framework store backed by
geodro/lerd-frameworkswithlerd framework search,lerd framework install, andlerd framework updatecommands. Definitions auto-fetch when linking a project and auto-refresh after 24 hours. MCP toolsframework_searchandframework_installexpose the store to AI assistants. (#103) - Framework-agnostic worker system — all hardcoded Laravel worker logic replaced with a generic system driven by framework YAML definitions. Dedicated commands (
queue,schedule,reverb,horizon) are now aliases that read from the framework definition. Workers supportconflicts_with, proxy config with auto port assignment, and port collision prevention across sites. - Worker add/remove CLI and MCP tools —
lerd worker addandlerd worker removemanage custom workers in.lerd.yaml(project-level) or the global framework overlay (--global). Orphaned workers (running units with no framework definition) are detected and surfaced inworker list,worker stop, and setup. - PHP version ranges — framework definitions declare supported PHP min/max ranges.
lerd linkandlerd initclamp the PHP version to the framework's supported range.lerd sitesand the UI show the framework version (e.g. "Laravel 11"). andtemplate vars — framework env var templates can reference the site's primary domain and TLS scheme..envkeys likeAPP_URL,VITE_REVERB_HOST, andVITE_REVERB_SCHEMEsync automatically when the primary domain changes.- Selenium service preset — bundled
seleniumpreset (selenium/standalone-chromium) for browser testing with Laravel Dusk. Auto-detected viacomposer_detectonlaravel/dusk, patchesDuskTestCase.php, and includes noVNC on port 7900 for watching tests live. Newshare_hostsfield on custom services maps.testdomains to the nginx container IP. - Cursor MCP support —
mcp:injectandmcp:enable-globalnow write Cursor configuration (.cursor/mcp.jsonand.cursor/rules/lerd.mdc). (#132) - Ghostscript in PHP-FPM —
ghostscriptadded to the base PHP-FPM image for PDF manipulation with libraries like Spatie MediaLibrary. (#138) - mysql-client in PHP-FPM —
mysql-clientadded to the PHP-FPM image somysqldumpworks insidelerd phpsessions. (#142)
Changed
- MCP tool responses optimised for AI agents — ANSI escape codes stripped from all CLI output.
doctor,check, andenv_checkreturn structured JSON instead of raw text.env:checkno longer exits non-zero. - CI auto-rebuilds PHP images — a scheduled workflow checks Docker Hub daily for upstream
php:X.Y-fpm-alpinesecurity patches and triggers a force rebuild when new digests appear.
Fixed
php:rebuildreused stale base images —lerd php rebuildnow always pulls fresh base images instead of building on top of potentially outdated cached layers. (#140)npm run buildfailed whennode_modulesmissing — build step is now guarded so it skips gracefully when dependencies haven't been installed. (#133)
[1.9.4] — 2026-04-10
Fixed
- Extra volume mounts lost after install/update —
lerd installrewrote nginx and service quadlets from raw templates, dropping extra volume mounts for projects outside$HOME. Mounts now survive install and update cycles.
[1.9.3] — 2026-04-10
Fixed
- Projects outside
$HOMEfailed with "chdir: No such file or directory" — the PHP-FPM and nginx containers only bind-mount$HOME, so projects in/var/www,/opt/projects, or similar paths could not be served or exec'd into. Lerd now automatically injects extra volume mounts into both containers when it detects a project outside the home directory. Mounts are added transparently duringlerd link,lerd park, or any exec command (lerd php,composer,laravel new) and cleaned up onlerd unlink/lerd unpark. (#120) - Env file keys appended instead of uncommented — when a
.envkey existed but was commented out (#DB_HOST=...),lerd envappended a duplicate instead of uncommenting the existing line in place.
Added
lerd doctorchecks for crun — warns whencrunis not installed, since it is the recommended OCI runtime for rootless Podman.
[1.9.2] — 2026-04-10
Fixed
- Site service badges missed .env-detected services — badges on the site detail panel only showed services declared in
.lerd.yaml. Now also scans the site's.envforlerd-{name}references (both built-in and custom services), matching the same auto-detection logic the Services tab already uses.
[1.9.1] — 2026-04-09
Fixed
- Queue workers silently lost on uninstall+reinstall —
queueStartExplicitran a Redis preflight that returned an error before the unit file was written. Install-timerestoreSiteInfrastructureruns before any services are started, so for sites withQUEUE_CONNECTION=redisthe write step always failed and the worker units stayed missing on disk while systemd remembered them asnot-found failed. The preflight is gone; the dependency now lives in the systemd unit itself.lerd-queue-<site>.servicedeclaresAfter=/Wants=for whatever the queue backend needs (lerd-redis.servicewhenQUEUE_CONNECTION=redis,lerd-mysql.service/lerd-postgres.servicefor database-backed queues) on top of the FPM container, andlerd-horizon-<site>.servicealways declareslerd-redis.service. systemd handles the activation order andRestart=alwayscovers the small ready-window between activation and the backing container accepting connections. - Preset-installed services not regenerated on reinstall —
restoreSiteInfrastructureonly handled inline custom services and built-in named refs. Preset references likemariadb-11(declared in.lerd.yamlasmariadb-11: {preset: mariadb, version: "11"}) fell through toensureServiceQuadlet, which only knows about built-ins, so the silently-swallowedunknown serviceerror left sites with no quadlet for any preset-installed service after an uninstall+reinstall cycle. The restore path now goes throughProjectService.Resolve()which already knows how to render both inline and preset references back into a concreteCustomService.
Changed
lerd statusshows[preset]for preset-installed services instead of grouping them under[custom]. Hand-rolled custom services keep the[custom]label.- Tagline reworded —
lerd --help, theinstall.shbanner, and the goreleaser GitHub release notes header now readLerd — Podman-powered local PHP dev environment for Linuxinstead ofLaravel Herd for Linux — …. - Services walkthrough (
docs/getting-started/services.md) updated to lead with the bundled preset flow for MongoDB, phpMyAdmin, and pgAdmin (lerd service preset <name>) instead of the hand-rolled YAML each one used to require. Adminer, Elasticsearch, and RabbitMQ stay as full YAML recipes since there's no preset for them yet. Adminer's port bumped to 8083 to avoid colliding with themongo-expresspreset on 8082.
[1.9.0] — 2026-04-09
Added
- Service presets — opt-in bundled service definitions surfaced via
lerd service preset(list / install) and a+picker on the Web UI's Services tab. First batch shipsphpmyadmin,pgadmin,mongo,mongo-express, andstripe-mockas embedded YAML that becomes a normal custom service once installed, so every existinglerd servicesubcommand (start/stop/remove/expose/pin) keeps working unchanged. Installed presets are filtered out of the picker; after install the user lands on the new service detail panel and the service auto-starts. - Multi-version preset families — presets can declare multiple versions in a single YAML (e.g.
mysql8.0/8.4/9.0,mariadb10.11/11.4) andlerd service presetshows version pills onlist, prompts for a version on install, and persists the chosen tag in.lerd.yaml. Family discovery groups versions by base name in both the CLI list and the Web UI picker. - Preset MCP tools —
service_preset_listandservice_preset_installexpose the preset catalog and install flow to AI assistants, sharing the install path with the CLI throughserviceops.InstallPresetByName. Re-runlerd mcp:injectin existing projects to pick up the new tool descriptions. - Custom service
files:field — declare inline-rendered config files materialised on the host and bind-mounted into the container, with optionalmode(octal perms) andchown: true(adds:Uso podman re-chowns to the container's non-root uid). Used by thepgadminpreset to ship aservers.json+pgpassthat autoconnects tolerd-postgres. Files re-render on everylerd service startso editing the YAML and restarting picks up changes. - Custom service
connection_url:field — non-built-in databases now get the same "Open connection URL" link surface as the built-in mysql/postgres services. The detail panel renders a real<a>element pointing atmysql://,postgresql://, ormongodb://so right-click "Copy link" works and left-click hands the URL to the user's registered DB client (DBeaver, TablePlus, Compass, etc.). - Recursive
service start—lerd service start <svc>now ensures every entry independs_onis up first, recursively, in both the CLI and the Web UI. Pairs with the existing recursive stop that takes dependents down before the parent. Starting any preset that depends on a built-in (phpmyadmin,pgadmin) auto-starts the database. - Preset dependency gating at install time — installing a preset whose dependency is another custom service (e.g.
mongo-expressonmongo) is rejected with a clear error until the dependency is installed first. Built-in deps (mysql, postgres) are auto-satisfied. The Web UI's Add button is disabled with a matching amber "install mongo first" hint. - Database service quality-of-life suggestions — the detail panel of every database service (mysql, postgres, and an installed
mongo) now shows a sky-blue suggestion banner offering to install its paired admin UI when missing. The banner is dismissable per-preset and the dismissal persists inlocalStorage. When the admin UI is installed, the header gains an Open phpMyAdmin / pgAdmin / Mongo Express button that auto-starts the admin service if needed. - Lerd health dot in the Web UI — the Lerd entry in the System list now reflects overall core health (green when DNS / nginx / watcher are all running, red when any is down, yellow when an update is available) instead of only the update flag. The lerd logo in the left rail gains a small yellow badge when an update is available and is clickable, jumping straight to the Lerd entry.
- One-click update terminal — when an update is available, the Lerd entry exposes an "Open terminal & update" button that POSTs to the new loopback-only
/api/lerd/update-terminalendpoint, which spawns the user's preferred terminal emulator (kitty / foot / alacritty / wezterm / ghostty / ptyxis / konsole / gnome-terminal / xfce4-terminal / tilix / terminator / xterm) runninglerd updateso the host can prompt for sudo and stream download progress. - Getting-started walkthroughs — new
docs/getting-started/laravel.md,symfony.md,wordpress.md, andservices.mdpages plus adocs/usage/lifecycle.mdreference covering how Lerd's units come up at boot and howstart/stop/autostartinteract.
Changed
autostartis now a single coherent switch —cfg.Autostart.Disabledis the canonical source of truth for whether lerd comes up at login. Toggling it enables/disables everylerd-*.containerquadlet (by adding/stripping the[Install]section so the podman generator stops emitting thedefault.target.wantssymlink) and everylerd-*.serviceunit (UI, watcher, per-site worker/queue/schedule/horizon/reverb/stripe) together. Toggling does not stop or start anything currently running — the user is in the middle of working and a session-level switch should not yank infrastructure out from under them. Uselerd start/lerd stopfor live state.lerd autostart trayremoved — the tray is now governed by the same single autostart switch as everything else. The standaloneautostart traysubcommand and thelerd-autostart.serviceunit file are gone.- Service display labels — the Web UI now shows phpMyAdmin, pgAdmin, MySQL, PostgreSQL, Meilisearch, Mailpit, RustFS, MongoDB, Mongo Express, and Stripe Mock with their proper casing.
Fixed
- Tray autostart was broken — the tray autostart path went through the now-removed
lerd-autostart.serviceshim and stopped enabling on fresh installs. The unified autostart toggle now covers the tray too, the per-unit autostart toggle is wired up correctly, andlerd installhonours the persisted autostart state.
[1.8.0] — 2026-04-09
Added
lerd lan:expose/lan:unexpose/lan:status— unified switch to share a lerd dev environment with another machine on the local network. Off by default; every container port now binds127.0.0.1(was0.0.0.0since v0.1.0), so untrusted wifi is safe out of the box. Service containers (mysql, postgres, redis, meilisearch, rustfs, mailpit) stay loopback-only even when LAN exposure is on; only nginx flips to0.0.0.0, since Laravel apps inlerd-php-fpmreach services through the podman bridge regardless of host bind. Quadlets are rewritten centrally viapodman.WriteQuadletDiffso flipping the switch only restarts units whose on-disk content actually changed.- Remote dashboard access — the dashboard at port 7073 is gated by two independent flags:
cfg.LAN.Exposedis the top-level kill switch andcfg.UI.PasswordHashadds HTTP Basic auth on top. LAN clients only reach the dashboard when both are set; loopback always bypasses both. Stale credentials cannot survivelan:unexpose. The dashboard's "Remote dashboard access" card distinguishes active / inert / disabled states so the user sees when credentials are stored but blocked bylan:unexpose. UI feedback during a toggle streams NDJSON progress events fromPOST /api/lan/status; the card polls every 5s while on the System tab so CLI toggles are reflected without a page reload. http://lerd.localhostas a usable bookmark —lerd-nginxserves the static dashboard HTML, icons, and PWA manifest from thelerd.localhostvhost, with/api/*explicitly returning 444 so a LAN curl forging the Host header cannot reachlerd-uithrough the proxy. The dashboard JS detects when it was loaded fromlerd.localhostand rewrites all fetch, EventSource, and favicon img srcs to absolutehttp://localhost:7073URLs so they hitlerd-uidirectly over loopback.lerd remote-setup— generates a one-shot 15-minute code and prints a curl one-liner the remote machine runs to install mkcert, trust the lerd root CA, and configure its resolver (NetworkManager+dnsmasq, systemd-resolved 254+, standalone dnsmasq, or macOS/etc/resolver). The endpoint is gated by token presence + RFC 1918 source IP + brute-force lockout. The bootstrap script's epilogue warns that the server IP is hardcoded into the resolver dropin and explains how to re-bootstrap if the server moves networks.app_urlfield in.lerd.yamlandsites.yaml— new precedence chain forAPP_URL:.lerd.yamlapp_url(committed, shared across machines) >sites.yamlapp_url(per-machine override) > the default<scheme>://<primary-domain>generator.lerd setupno longer overwrites a customAPP_URLon every run — set it once in.lerd.yamland lerd respects it. The.lerd.yamlapp_urlis silently suppressed when its host points at a domain that the conflict filter dropped, so.envnever ends up writing a hostname owned by another site.- Soft-fallback domain conflict handling — when
lerd linkor the parked-directory watcher tries to register a domain another site already owns, the conflicting domain is now filtered out (instead of failing the whole link) and a clear WARN line is printed naming the owning site. Surviving domains still register; if every domain conflicts, lerd falls back to a freshly generated<dirname>.<tld>with a numeric suffix..lerd.yamlis never modified on disk — the originaldomains:list stays so the conflict is visible to the UI and self-heals on the next link if the owning site is removed. - Domain conflict UI surface — the site detail header's "+N more" pill now counts conflicted domains and shows an amber warning icon when present (hover reveals each conflicted entry with the owning site name). The Manage Domains modal renders conflicted entries at the top with a warning icon, the domain struck-through, a "used by <site>" pill, and a small trash button that removes the entry from
.lerd.yamlonly (no registry, vhost, or cert touched). Thedomain:removeserver action detects conflict-filtered entries and routes them to a.lerd.yaml-only delete path. [Remote Access]section inlerd status— new block showing LAN exposure state and dashboard remote-access state, with hints when off. Refactored into a testableprintRemoteAccessStatushelper.- Tray "Expose to LAN" toggle — new menu item that shells out to
lerd lan expose / unexpose, mirroring the autostart toggle. - Dynamic colour tray icon — white L when lerd is running, red L when stopped. The default flag flipped from
--mono=trueto--mono=falseso the colour icon is what users see by default; mono mode is still available for OS-recoloured template icons. The icon's dark background was stripped so it's transparent on the panel.
Changed
- Tray "Open Dashboard" opens
http://lerd.localhostinstead of the bare127.0.0.1:7073loopback URL. Tray API polling stays on loopback so the tray works before nginx is up. - Tray paused services render with a yellow dot instead of red, so user-initiated stops are visually distinct from broken services.
lerd doctor"linger enabled" check renamed to "systemd linger" so the WARN row no longer reads as if linger is in fact enabled.
Fixed
lerd uninstallleft the tray running — the uninstall flow stopped and disabled all systemd units but never killed standalone tray processes (launched from the desktop file orlerd tray). The tray kept running after the binary was gone, with no way to dismiss it short ofpkill. Uninstall now calls the existingkillTrayhelper after the unit teardown.lerd installhang when installing the Laravel installer — the installer prompted for the Laravel installer on every run and then shelled out through the composer shim, which routes throughlerd phpand depends on cwd-based PHP detection. When the install command runs from$HOMEwith no project metadata, detection fell back tocfg.PHP.DefaultVersionand handed composer to a possibly-missing container. Worse,composer global requiretriggers symfony/flex / plugin trust prompts which sat invisibly insidepodman exec -t -i, making the whole step look stuck with no output. Fixed by skipping the prompt entirely when no PHP version is installed, and when it does run, bypassing the shim — picking a known-installed PHP (preferring the configured default), ensuring its FPM container is running, andpodman exec'ingcomposer global require --no-interaction laravel/installerdirectly.
[1.7.1] — 2026-04-08
Added
- Database picker in
lerd init— the wizard's services step is now split into a single-choice Database select (sqlite / mysql / postgres) and a multi-select for everything else. The default is seeded from any database already in.lerd.yaml, thenDB_CONNECTIONin.env(or.env.examplefor fresh clones), falling back to SQLite. After the wizard completes,lerd envruns automatically so the choice immediately lands in.env— picking MySQL/PostgreSQL writes the connection vars and creates the project database (plus_testing), picking SQLite writesDB_CONNECTION=sqliteand createsdatabase/database.sqliteif it's missing. - Runtime database prompt in
lerd env— when run interactively on a Laravel project whose.envsaysDB_CONNECTION=sqliteand whose.lerd.yamldoesn't yet pick a database,lerd envnow prompts for a deliberate choice (Keep SQLite / MySQL / PostgreSQL) and persists it so subsequent runs don't re-ask. Skipped automatically when stdin isn't a TTY (CI, MCP, scripted runs) and for frameworks with explicit env service rules (Symfony, WordPress, etc.) that don't useDB_CONNECTION. db_setMCP tool — pick the database for a Laravel project from an AI assistant:db_set(database: "sqlite" | "mysql" | "postgres"). Persists the choice to.lerd.yaml(replacing any prior database — the choice is exclusive), rewrites theDB_keys in.env, starts the service if needed, and creates the database (or the SQLite file). The companionenv_setuptool's description now points atdb_setso AI assistants know to call it beforeenv_setupon fresh Laravel clones —env_setupalone leavesDB_CONNECTION=sqliteuntouched.- SQLite as a first-class env-time choice —
serviceEnvVars["sqlite"]now appliesDB_CONNECTION=sqliteandDB_DATABASE=database/database.sqlite. Thelerd envflow special-cases sqlite so it isn't treated as a podman service: no quadlet, noservice_start, just the env vars and the file creation. The user's database choice in.lerd.yamlis authoritative — switching from mysql → sqlite (or vice versa) skips the auto-detection of the previous database in.env.
Fixed
vendor_bins/vendor_runmissing from injected MCP skills — the new vendor/bin tooling shipped in v1.7.0 was registered with the MCP server but absent from the skill content thatlerd mcp:injectwrites into.claude/skills/lerd/SKILL.mdand.junie/guidelines.md, so AI assistants weren't told the tools existed. Both files now describe the tools with examples for pest, phpunit, pint, phpstan, and rector. Re-runlerd mcp:injectin existing projects to pick up the updated skill content.
[1.7.0] — 2026-04-08
Added
- Application log viewer in the UI — site detail view now has an App Logs tab that parses application log files into a structured table with level, date, and message columns, expandable to show full stacktraces. Frameworks declare log file locations and parser format via a new
logsfield in their YAML; Laravel defaults tostorage/logs/*.logwith Monolog parsing. Auto-selects the site with the most recent log activity on page load, refreshes every 5 seconds, and supports search filtering plus a Latest/All toggle. Entries display oldest-first (newest at the bottom), pinned to the bottom on every refresh, matching the streaming container/queue/worker log panes. vendor/binshortcuts andlerd test/lerd aaliases — any composer-installed binary in the project'svendor/binis now callable directly aslerd <name>(e.g.lerd pest,lerd pint,lerd phpstan), routed through the project's PHP-FPM container withvendor/binprepended toPATH. Built-in lerd commands always win on name collisions. Two new shortcuts:lerd a(alias forartisan) andlerd test(shortcut forartisan test). The same surface is exposed to MCP clients viavendor_bins(list) andvendor_run(execute). Closes #101.- Laravel installer shipped globally —
lerd installnow offers to installlaravel/installeras a global composer package and creates alaravelshim inBinDirrouted throughlerd php, so thelaravelcommand works directly in the terminal the way Herd ships it. The prompt defaults to yes and runs before the parallel TUI to avoid stdin conflicts. Closes #98. - Site favicons in the UI — the UI detects
favicon.ico/svg/pngin each site's public directory and serves them viaGET /api/sites/{domain}/favicon. The sites list and detail header now display the favicon when available, falling back to the status dot.
Changed
- PHP and Node version selects deferred until loaded — the version dropdowns in the site detail view now show static placeholders while the version lists are still loading, preventing the browser from resetting
selectedSite.php_version/node_versionto an empty string and causing spurious change events.
Fixed
- Dark mode dropdown readability — the PHP and Node version selectors now apply explicit option background and text colors so the dropdown menu is readable in dark mode.
[1.6.3] — 2026-04-06
Changed
- Tray switched to libayatana-appindicator — the system tray now uses the actively maintained ayatana fork instead of the legacy libappindicator3. No behavior change; ayatana is the default backend in getlantern/systray and is already present on Ubuntu desktops.
lerd updatedefaults to yes — pressing Enter now confirms the update instead of cancelling.
Fixed
- DNS broken on systems without NetworkManager — the resolved drop-in file was written with 0600 permissions (unreadable by systemd-resolved), breaking
.testdomain resolution on omarchy and similar systems. Fixed by setting correct permissions (0644) viasudoWriteFile. - Sudoers missing resolved paths — extended the sudoers drop-in to cover systemd-resolved config paths for passwordless install/start on resolved-only systems.
[1.6.2] — 2026-04-06
Fixed
- MissingAppKeyException on fresh project —
lerd envnow generatesAPP_KEYdirectly in.envwhenvendor/does not exist yet, instead of failing silently onartisan key:generate. This prevents Laravel'sMissingAppKeyExceptionduringcomposer installpost-install scripts in thelerd new→lerd link→lerd setupflow. composer installusing wrong PHP version in setup —lerd setupnow runscomposer installinside the project's PHP-FPM container, matching thecomposer.jsonPHP constraint. Previously it used the host composer shim which could resolve to the global default PHP version.- PHP version detection from
composer.jsonignores installed versions — the constraint resolver now picks the highest installed PHP version satisfying thecomposer.jsonrequire.phpconstraint (e.g.^8.3with 8.3 and 8.4 installed → 8.4). Supports^,~,>=,<,||,*, and AND constraints. Falls back to the literal minimum when no installed version matches.
[1.6.1] — 2026-04-06
Fixed
- Fresh install missing default PHP-FPM —
lerd installnow always builds and starts the default PHP version, even with no registered sites. Previouslylerd newwould fail on a fresh install because no PHP-FPM container existed. - Install not restoring services —
lerd installnow restores service quadlets (mysql, redis, custom services) from.lerd.yaml, pulls missing images, and starts them. Workers no longer fail on reinstall because their dependencies are running. - Install not restoring workers —
lerd installnow callsrestoreSiteInfrastructureto recreate worker units from.lerd.yamlafter services are started. - FPM not restored for sites using default PHP — both
lerd installandlerd startnow fall back to the configured default PHP version when a site has no explicitPHPVersion, instead of skipping it. - UI stripe toggle not syncing
.lerd.yaml— toggling the Stripe listener from the web UI now writes the workers list to.lerd.yaml, matching the behaviour of all other worker toggles. - Uninstall spinner with no expandable output — replaced the StepRunner spinner (Ctrl+O did nothing) with the same
step()/ok()output style used by install.
[1.6.0] — 2026-04-06
Added
- Framework setup commands — framework definitions now support a
setupfield with one-off bootstrap commands (migrations, storage links, fixtures) shown inlerd setup. Laravel's hardcoded storage:link/migrate/db:seed steps are now part of the built-in framework definition. Custom frameworks define their own via YAML. - Conditional checks on workers and setup commands — both
workersandsetupentries support an optionalcheckfield (fileorcomposer) to conditionally show them based on project dependencies (e.g. messenger worker only shown whensymfony/messengeris installed). - Service version placeholders — framework env vars support
,,, andplaceholders, resolved from the running service image tag atlerd envtime. --setupflag forlerd framework add— define setup commands via CLI flags in addition to YAML.- Link modal streaming logs — the web UI link modal now streams
lerd linkandlerd envoutput line-by-line instead of showing only a spinner. - Domain modal success feedback — add/edit/remove domain operations in the web UI now show a flash message on success.
- omarchy OS support — systems with systemd-resolved but no NetworkManager can now install and run lerd. The installer accepts either resolver.
- Reverb prerequisite check —
lerd reverb:startandlerd reverb:stopnow check forlaravel/reverbin composer.json before proceeding, with install instructions and a link to the Laravel Broadcasting docs.
Changed
- Worker state synced to
.lerd.yaml— all worker start/stop commands (queue,schedule,reverb,horizon,stripe:listen,worker start/stop) now persist the active workers list in.lerd.yamlwhen the file exists. Previouslyworker start/stopandstripe:listendid not update the file. lerd startrestores site infrastructure — after an uninstall/reinstall cycle,lerd startreads.lerd.yamlfrom each active site and recreates missing FPM quadlets, service quadlets, and worker units automatically.lerd installrestores FPM quadlets — reinstalling now restores PHP-FPM quadlets for all PHP versions used by registered sites, not just the default version.- Improved
lerd uninstall— stops alllerd-*systemd units (workers, stripe listeners, etc.) instead of only the hardcoded watcher and UI services. DNS teardown and the data-removal prompt now run before the step runner to avoid stdin conflicts.
Fixed
- DNS teardown leaves stale DNS on virtual interfaces —
lerd uninstallnow reverts all network interfaces that have lerd DNS configured (e.g.virbr0,vnet*), not just the default interface. - Internet DNS broken after uninstall — after reverting interfaces and restarting NetworkManager, lerd now explicitly pushes the DHCP-assigned upstream DNS servers so name resolution works immediately.
- Domain modal stale state — the web UI domain modal now properly updates the domain list after add/edit/remove operations. The site list merge was matching by domain (which changes) instead of name (stable).
lerd envruns automatically in setup —lerd envnow runs at the start oflerd setupinstead of being a selectable step, ensuring.envis configured beforecomposer installtriggers post-install scripts.- Definition conflict resolution — when
.lerd.yamland the local framework/service definition differ, lerd now offers a three-way choice: use .lerd.yaml version, use local definition, or skip. Both sync directions persist immediately. - Improved horizon/reverb error messages — error messages now include install commands and docs links instead of generic text.
- Dynamic DNS resolver hints —
lerd doctorandlerd statusnow show the correct restart command based on the active resolver instead of always suggesting "restart NetworkManager".
Docs
- Added contributing section to nav bar, stripe page to usage sidebar, troubleshooting to reference sidebar
- Fixed
placeholders being swallowed by VitePress (Vue template interpolation) - Replaced non-rendering mermaid chart with ASCII diagram on architecture page
- Added reverb prerequisite note to commands reference
- Updated requirements, architecture, and troubleshooting for systemd-resolved support
[1.5.1] — 2026-04-04
Fixed
- Nginx fails to start when TLS certificates are missing —
lerd startnow detects SSL vhosts referencing missing cert files before starting nginx, switches affected sites back to HTTP, and removes orphan SSL configs. Previously a single missing certificate would prevent all sites from loading. - Paused sites bypass landing page after update —
lerd install(called bylerd update) was regenerating vhosts for all sites, overwriting paused landing pages with the full site config. Paused and ignored sites are now skipped during vhost regeneration. - Paused landing page redesigned — the paused page now matches the branded "Site Not Found" page with the Lerd logo, red accent, and Resume + Dashboard buttons. Uses a single shared HTML file instead of generating one per site.
[1.5.0] — 2026-04-04
Added
- Multi-domain support — sites can now respond to multiple
.testdomains. Uselerd domain add,lerd domain remove, andlerd domain listto manage them. Domains are stored in.lerd.yamland the certificate is reissued automatically when a domain is added to a secured site. lerd env:checkcommand — compare all.envfiles against.env.exampleand flag missing or extra keys. Exits non-zero when required keys are missing.lerd checkcommand — validate.lerd.yamlsyntax, PHP version, Node version, services, frameworks, and workers before running setup. Reports OK/WARN/FAIL per field.lerd whichcommand — show the resolved PHP version, Node version, document root, and nginx config paths for the current site.- Port conflict detection —
lerd startchecks for port conflicts before starting containers and warns if another process is already using a required port. lerd update --beta— update to the latest pre-release build from GitHub.lerd update --rollback— revert to the previously installed version using the automatic backup.- Automatic PHP/Node version switching — the watcher monitors
.lerd.yaml,.php-version,.node-version, and.nvmrcand automatically re-links the site when versions change. - Workers in
lerd init— the wizard includes a workers step that pre-selects workers based on the framework and installed packages. Horizon is auto-detected fromcomposer.json. - Setup prompt on link — when linking a site with workers configured in
.lerd.yaml, lerd prompts to runlerd setupto install dependencies and start workers. - Branded error pages — requests to unlinked
.testdomains show a styled "Site Not Found" page with links to the dashboard instead of a generic browser error. - Failing worker visibility —
lerd statusshows failing and restarting workers across all sites. The web UI shows a pulsing red toggle and a "!" indicator on the log tab for failing workers.
Fixed
- Crash-looping workers left running after unlink —
lerd unlinknow detects and stops crash-looping workers for the site. - Paused sites counted in status workers section — paused sites are now excluded from the workers list in
lerd status. - Paused sites counted in TLS check —
lerd statusno longer flags TLS issues for paused or ignored sites. - Service container left behind on remove —
lerd service removenow properly cleans up the Podman container.
[1.4.2] — 2026-04-03
Fixed
- Paused sites counted in service badges and auto-stop logic — paused sites were included when counting how many sites use a service, so services stayed active and their site-count badges inflated even after all active sites were paused. Paused sites are now excluded from
CountSitesUsingServiceand the badge tooltip list.
[1.4.1] — 2026-04-03
Fixed
- 3-pane dashboard layout missing from v1.4.0 — the new icon rail, list panel, and full-height detail panel were lost during a merge conflict resolution. The correct UI is now restored.
[1.4.0] — 2026-04-03
Added
- 3-pane dashboard layout — the UI is redesigned around a persistent icon rail (Sites, Services, System), a scrollable list panel, and a full-height detail panel. Logs fill remaining height rather than being capped at a fixed box. Works at any scale from 1 to 50+ sites. Mobile gets a full-screen list/detail with a bottom tab bar and a back button.
- PHP-FPM auto-lifecycle — FPM containers for unused PHP versions are stopped automatically on
lerd unlinkandlerd start. Paused sites keep their FPM running. Onlerd start, only versions referenced by at least one site are started. When a site is unpaused, its FPM container is guaranteed running before nginx is restored. - Manual FPM start/stop from the dashboard — unused PHP versions (no active sites) show a Stop button in the dashboard when running. Stopped unused versions are shown with a neutral badge rather than an error.
lerd startparallel spinner UI — start and stop operations now show a live per-unit progress display. All images required by units are checked and rebuilt or pulled before containers start.- Site pills on services — core services (MySQL, Mailpit, etc.) and worker-type services (Queue, Horizon, Reverb, etc.) show clickable site pills. Clicking a pill navigates directly to that site's settings.
- Clickable PHP-FPM site pills — site pills on the PHP-FPM detail panel now navigate to the site's settings panel instead of opening the browser.
- Instant system theme switching — when the theme is set to Auto, the dashboard switches between light and dark immediately as the OS preference changes, without a page reload.
Fixed
lerd statusfalse errors for stopped unused PHP-FPM — stopped FPM containers for versions not referenced by any site are now reported as warnings, not errors.- MinIO migration prompt shown after already migrating to RustFS — the
lerd updatemigration prompt now also checks whether thelerd-miniocontainer is running, so users who have already migrated are not prompted again. - Pre-built PHP base images required ghcr.io login — lerd now always pulls base images anonymously to avoid authentication errors from expired or unrelated ghcr.io credentials.
[1.3.3] — 2026-04-02
Fixed
- Broadcasting jobs fail when
lerd envwas run on a Reverb site —REVERB_HOSTwas set to the site domain (e.g.my-app.test), which resolves inside the PHP-FPM container tohost.containers.internal(169.254.1.2). That address — the nginx proxy on the host — is not reachable from inside the container's network namespace, so every broadcast job failed with cURL error 7.REVERB_HOST,REVERB_PORT, andREVERB_SCHEMEare now always written aslocalhost,REVERB_SERVER_PORT, andhttpso the queue worker connects to Reverb directly inside the same container.VITE_REVERB_HOST/PORT/SCHEMEcontinue to use the site domain and external port for browser connections through nginx. Sites affected can be fixed by re-runninglerd env. - Log lines repeating on SSE reconnect — when the browser reconnected to a log stream (network blip, tab restore) the entire history was replayed from the start. For systemd units the stream now emits the journalctl cursor as the SSE event id and resumes with
--after-cursoron reconnect; for Podman containers a monotonic line counter is used and--tail 0skips history on reconnect.
[1.3.2] — 2026-04-01
Fixed
- Queue log streaming was a stale duplicate of the shared implementation — the
/api/queue/<site>/logsSSE handler had its own inline copy of the log streaming logic instead of calling the sharedstreamUnitLogshelper used by every other worker (horizon, schedule, reverb, stripe). The duplicate is removed.
[1.3.1] — 2026-04-01
Fixed
- PHP FPM fails to start on fresh installs — the shared hosts file (
~/.local/share/lerd/hosts) is bind-mounted into every PHP-FPM container. If no site had ever been linked, the file did not exist and podman refused to start the container withstatfs: no such file or directory.WriteFPMQuadletnow ensures the file is created before the container is started.
[1.3.0] — 2026-04-01
Added
- Multiple Reverb sites without port collisions — when
lerd envdetectsBROADCAST_CONNECTION=reverb, it auto-assigns a uniqueREVERB_SERVER_PORTper site starting at 8080 and incrementing for each additional site.reverb:start(including the UI toggle) also assigns and persists the port on first start if still missing, so the fix applies even whenlerd envhas not been re-run. The nginx WebSocket proxy uses the per-site port instead of the old hardcoded 8080. Fixes #47. - New MCP tools:
db_import,db_create,php_list,php_ext,park,unpark— six new tools for AI agents covering database import from a SQL file, on-demand database creation, listing installed PHP versions, managing PHP extensions, and parking/unparking directories. lerd whatsnew— new command that prints the changelog for the currently installed version. The changelog excerpt has been removed fromlerd statusandlerd doctoroutput.- Portable
.lerd.yaml—.lerd.yamlcan now describe a site's full local environment (PHP version, Node version, framework, services, custom workers). Runninglerd linkin a project that has a.lerd.yamlapplies all settings automatically, so cloning a project and runninglerd link && lerd envis enough to reproduce the full environment. Closes #33. - Pre-built PHP base images — PHP images are now built on top of pre-built base images pulled from
ghcr.ioinstead of compiling all extensions from source. First-install time drops from ~5 minutes to ~30 seconds. Closes #43.
[1.2.4] — 2026-03-31
Added
lerd php:rebuildaccepts a version argument — pass a version (e.g.lerd php:rebuild 8.3) to rebuild only that PHP image instead of all installed versions.
Fixed
- Inter-application
.testdomain resolution inside containers — HTTP/HTTPS requests from one site to another (e.g.booking.testcallingstaffing.test) were failing because.testdomains resolved to127.0.0.1inside containers, which points to the container itself rather than the host Nginx. A shared hosts file (~/.local/share/lerd/hosts) is now bind-mounted into every PHP-FPM container at/etc/hostswith a169.254.1.2entry per linked site. Since it is a bind mount,lerd linkandlerd unlinkupdate all running containers instantly without a restart. Fixes #39. - Reverb proxy returns 502 after container restart — the Nginx
location /appblock used a bare hostname inproxy_pass, which Nginx resolves once at config load time. If the PHP-FPM container restarted and received a new IP, subsequent WebSocket and broadcast requests failed with 502. The proxy now uses a variable (set $reverb) to force per-request DNS resolution, matching how the FastCGI location already handles the FPM upstream.
[1.2.3] — 2026-03-31
Added
- Horizon appears in the Services panel — when Laravel Horizon is running for a site it now shows up as its own entry in the Services panel (grouped under "Horizon"), with a stop button, live log stream, and a subtitle showing the site domain. Previously Horizon was only visible in the site detail view.
- Starting Horizon stops the queue worker —
horizon:start(CLI, UI, MCP) now automatically stops any running queue worker for the same site before starting Horizon, since the two must not run simultaneously. lerd unlinkstops all workers for the site — queue workers, Horizon, schedule workers, Reverb, Stripe listeners, and custom framework workers are all stopped before the site is unlinked.
Fixed
- Tray no longer shows per-site workers — Reverb, Horizon, queue workers, schedule workers, Stripe listeners, and custom framework workers are filtered out of the tray menu. Only real infrastructure services (MySQL, Redis, Mailpit, etc.) are listed there.
lerd phpcan now run scripts outside$HOME— IDEs like PhpStorm write their validation scripts to/tmpand callphp -d... /tmp/ide-phpinfo.php. The container only mounts$HOME, so those scripts were unreachable and produced an empty output ("Failed to parse validation script output").runPhpnow detects any argument that is an absolute path to a host file outside$HOME, reads it, and streams it to the container viastdin//dev/stdin.- Horizon logs in the Services panel now stream the correct site — the logs URL for a Horizon service entry now routes to
/api/horizon/{site}/logs(systemd journal) instead of the generic/api/logs/lerd-horizon-{site}endpoint that tried to usepodman logson a non-existent container. - Horizon log tab on the Sites panel no longer shows stale logs from a previous site — switching sites now properly closes and clears the Horizon log stream; clicking the Horizon tab reconnects to the correct site's stream.
[1.2.2] — 2026-03-31
Added
lerd initvalidates PHP version input — the PHP version prompt now rejects invalid input such as8,5or plain strings; onlyMAJOR.MINORnumeric format (e.g.8.3) is accepted.lerd initandlerd envdetect services from.env.example— when.envis absent, service detection falls back to.env.exampleso a freshly cloned project is configured correctly before.envis created.lerd envwaits for services to be ready before creating databases and buckets — after starting MySQL, PostgreSQL, or RustFS, lerd now polls for readiness (mysqladmin ping/pg_isready/ TCP dial) before attempting to create the database or bucket. Previously the create step could silently fail if the container had not finished initialising.- Automatic quadlet restoration for orphaned PHP FPM containers —
lerd php:list(and any command that callsListInstalled) now scanspodman ps -aforlerd-php*-fpmcontainers whose quadlet file is missing and restores it automatically, so users who lost their quadlet files do not need to reinstall PHP.
Fixed
lerd initinstalls PHP FPM with a progress indicator — when the required PHP FPM version is not yet installed,lerd initnow shows a spinner rather than silently blocking. (PR #34)
[1.2.1] — 2026-03-31
Fixed
mcp:injectandmcp:enable-globalfail on empty JSON config files —mergeMCPServersJSONnow skipsjson.Unmarshalwhen the target file exists but is empty, preventing a spurious "unexpected end of JSON input" error. Affects~/.ai/mcp/mcp.json,~/.junie/mcp/mcp.json, and.mcp.json. (PR #31)lerd newrunscomposer installwith the wrong PHP version —composer create-projectfor Laravel now passes--no-install --no-plugins --no-scriptsso dependency installation is deferred tolerd setup, where the correct PHP version is already active. (PR #28 by @voronkovich)- Duplicate
export PATHentries written to.zshrcon repeatedlerd install—appendShellRCnow checks whether the PATH line already exists before appending. (PR #30 by @voronkovich) - Redundant
appendShellRCcall writes a brokenexport PATH=":$PATH"line to.zshrc— the call with an emptybinDirhas been removed;ensureZshFpathalready handles the fpath setup. (PR #29 by @voronkovich)
[1.2.0] — 2026-03-30
Added
lerd init— interactive wizard that writes PHP version, HTTPS preference, and required services to.lerd.yamlfor project portability. On a machine with an existing.lerd.yaml,lerd initapplies the saved config non-interactively, making new-machine setup a single command.lerd setupnow runs the wizard as its first step,lerd linkauto-secures whensecured: trueis set, andlerd env/lerd isolate/lerd secureall keep the file in sync.lerd console— run a framework's interactive console (e.g.php artisan tinkerfor Laravel, or theconsolefield from the framework YAML) inside the project container. Arguments are forwarded as-is.consoleMCP tool — execute framework console commands from an AI assistant session. Resolves the correct binary viaconfig.GetConsoleCommandso it works for any framework that defines aconsolefield.- Cloudflare Tunnel backend for
lerd share— pass--cloudflareto tunnel a site viacloudflared. Without the flag, lerd auto-detects between ngrok and Expose as before. The tunnel is routed through the host proxy to fix Host header and TLS SNI for secured sites. - pcov bundled in PHP-FPM images — pcov is now pre-installed via PECL in all lerd PHP-FPM images;
lerd php:ext add pcovis no longer needed to runpest --coverage. - WebP support in PHP-FPM images — gd and imagick now include WebP support out of the box (PR #15 by @ReyArlena).
- Connection URLs and hostname note in the dashboard — database service cards now show ready-to-use connection URLs alongside a note about the internal container hostname.
Fixed
- Paused site vhosts overwritten on watcher restart —
scanWorktrees()now skips paused sites on startup; worktree vhost generation and nginx reloads triggered by.php-versionchanges are also skipped while a site is paused (registry is still updated for when the site is unpaused). lerd consolefalls back toartisanfor Laravel — when a Laravel project's framework YAML has no explicitconsolefield,lerd consolenow correctly usesphp artisan.
Internal
- Unit tests for
config,php,distro, andenvfilepackages.
[1.1.2] — 2026-03-30
Fixed
lerd installno longer hangs after "Adding shell PATH configuration" — the interactive MCP registration prompt has been removed. Runlerd mcp:enable-globalmanually after install to register the MCP server.- Dashboard URL in install completion message — now shows
http://lerd.localhostinstead of the rawhttp://127.0.0.1:7073address.
[1.1.1] — 2026-03-30
Added
- CI badge on README — the README now shows a live CI status badge linked to the
ci.ymlworkflow.
Fixed
- MCP registration prompt unresponsive when installing via pipe —
lerd installreads the "Register lerd MCP globally?" prompt answer from/dev/ttyinstead of stdin. When the installer is run via a pipe (curl ... | sh), stdin is the pipe andfmt.Scanreturns immediately with no input; opening/dev/ttydirectly reads from the actual terminal regardless of how the process was started.
Internal
- Release workflow now gates on CI — the
release.ymlworkflow runs build, test, vet, and format checks before invoking GoReleaser. A tag push on a broken commit will now fail before any artifacts are published.
[1.1.0] — 2026-03-30
Added
lerd new <name-or-path>— scaffold a new PHP project using the framework'screatecommand. Defaults to Laravel (composer create-project laravel/laravel). Pass--framework=<name>to use any framework that defines acreatefield. Extra args can be forwarded to the scaffold command after--. Theproject_newMCP tool provides the same functionality for AI assistants.createfield in framework definitions — framework YAML files now support acreateproperty (e.g.create: composer create-project symfony/skeleton). The target directory is appended automatically bylerd new. The--createflag was also added tolerd framework add.project_newMCP tool — scaffold a new project from an AI assistant session. Acceptspath(required),framework(default:laravel), andargs(extra scaffold flags). Follow withsite_linkandenv_setupto register and configure the new site.lerd mcp:enable-global— registers the lerd MCP server at Claude Code user scope (and Windsurf / JetBrains Junie global configs) so lerd tools are available in every AI session without per-project configuration. Duringlerd install, if Claude Code is detected and lerd is not yet registered, the installer prompts to run this automatically.site_phpMCP tool — change the PHP version for a registered site from your AI assistant. Writes.php-version, updates the site registry, regenerates the nginx vhost, and reloads nginx in one call. The target FPM container must be running.site_nodeMCP tool — change the Node.js version for a registered site. Writes.node-versionand installs the version via fnm if not already present.- CWD fallback for MCP path resolution — the MCP server now falls back to the working directory Claude was opened in when
LERD_SITE_PATHis not set. This meanspathcan be omitted fromartisan,composer,env_setup,site_link,db_export, and other tools when running in a global MCP session — just open Claude in the project directory.
Fixed
lerd setupnpm step fails without a lockfile — the npm install step now runsnpm ciwhenpackage-lock.jsonoryarn.lockis present, and falls back tonpm installotherwise. Previouslynpm ciwas always used, causing the step to fail on projects without a lockfile. (PR #5 by @voronkovich)- Duplicate
PATHentry onlerd install—add_to_pathininstall.shnow checks the live$PATHbefore modifying shell rc files. If the install directory is already present, the function returns early and skips rc modification. (PR #7 by @voronkovich) - zsh completions moved to XDG directory — zsh completions are written to
~/.local/share/zsh/site-functions/_lerdinstead of~/.zfunc/_lerd, aligning with the XDG base directory convention. (PR #8 by @voronkovich) .php-versionchanges not reflected in nginx — writing a.php-versionfile (vialerd isolateor directly) updated the queue worker but left the nginx vhost pointing at the old FPM socket. The watcher daemon now detects when the resolved PHP version changes, updates the site registry, regenerates the vhost, and reloads nginx automatically (debounced to 2 seconds).- PHP version resolution order —
.php-versionnow takes priority overcomposer.json'srequire.phpconstraint, matching the documented and intuitive precedence (explicit pin beats inferred constraint).
[1.0.4] — 2026-03-26
Fixed
.testdomains unavailable from PHP-FPM containers — v1.0.3 fixed internet access by setting real upstream DNS servers (e.g.192.168.0.x) on thelerdPodman network, but this caused aardvark-dns to skip systemd-resolved, breaking.testresolution from inside containers.lerd startandlerd installnow use pasta's built-in DNS proxy at169.254.1.1(read from the rootless-netnsinfo.json) as the aardvark-dns upstream. This address chains through systemd-resolved, which routes.testqueries to lerd-dns and forwards all other queries to real upstream servers — giving containers both.testresolution and full internet access.- HTTPS to
.testsites fails from inside PHP-FPM containers (cURL error 60) — PHP code making outbound HTTPS requests to local.testdomains (e.g. Reverb broadcasting, internal API calls) received SSL certificate errors because the mkcert root CA was not trusted inside the container. The PHP-FPM image build now copies the mkcert root CA into the Alpine trust store (update-ca-certificates), so all.testHTTPS certificates are trusted. Existing images are automatically rebuilt onlerd update. - Reverb / queue / schedule workers not restarted after
php:rebuild— whenphp:rebuildreplaced and restarted the PHP-FPM containers, workers running inside those containers viapodman exec(Reverb, queue, schedule) were killed by theBindsTosystemd dependency but not brought back up automatically.php:rebuildnow explicitly restarts all such workers after the containers are back online.
[1.0.3] — 2026-03-26
Fixed
- No internet access from PHP-FPM containers — on systems where
/etc/resolv.confpoints to a stub resolver (127.0.0.53via systemd-resolved), aardvark-dns could not forward external DNS queries because the stub address is only reachable on the host's loopback, not from inside the container network namespace.lerd startandlerd installnow detect the real upstream DNS servers (reading/run/systemd/resolve/resolv.conffirst) and set them on thelerdPodman network so aardvark-dns forwards correctly.
[1.0.2] — 2026-03-25
Added
- RustFS replaces MinIO — MinIO OSS is no longer maintained; lerd now ships RustFS as its built-in S3-compatible object storage service. RustFS exposes the same API and credentials (
lerd/lerdpassword) so no application changes are needed. Closes #3. lerd minio:migrate— one-command migration from an existing MinIO installation to RustFS. Stops the MinIO container, copies data to the RustFS data directory, removes the MinIO quadlet, updatesconfig.yaml, and starts RustFS. The original MinIO data directory is preserved for manual cleanup.- Auto-migration prompt during
lerd update— if a MinIO data directory is detected at update time, lerd offers to run the migration automatically before continuing. lerd.localhostcustom domain — the Lerd dashboard is now accessible athttp://lerd.localhost(nginx proxies the domain to the UI service).lerd dashboardopens the new URL..localhostresolves to127.0.0.1natively on all modern systems with no DNS configuration.- Installable PWA — the dashboard ships a web app manifest (
/manifest.webmanifest) and SVG icons so it can be installed as a standalone app from Chrome or other PWA-capable browsers.
Fixed
- 502 Bad Gateway on Inertia.js full-page refreshes — nginx vhost templates now include
fastcgi_buffers 16 16kandfastcgi_buffer_size 32k, preventingupstream sent too big headererrors caused by large FastCGI response headers (common on routes with heavy session/flash data).
[1.0.1] — 2026-03-25
Added
lerd shell— opens an interactiveshsession inside the project's PHP-FPM container. The PHP version is resolved the same way as every other lerd command (.php-version,composer.json, global default). The working directory is set to the site root. If the site is paused, any services referenced in.envare started automatically before the shell opens.- Shell completions auto-installed on
lerd install— fish completions are written to~/.config/fish/completions/lerd.fish; zsh completions to~/.zfunc/_lerdwith the requiredfpathandcompinitlines appended to.zshrc; bash completions to~/.local/share/bash-completion/completions/lerd. - Pause/unpause propagates to git worktrees — when a site is paused, all its worktree checkouts also receive a paused nginx vhost with a Resume button. The button targets the parent site so clicking it unpauses both the parent and all worktrees at once. Unpausing restores all worktree vhosts and removes the paused HTML files.
Fixed
lerd parkrefuses to park a framework project root — if the target directory is itself a Laravel/framework project, lerd now prints a helpful message and suggestslerd linkinstead of silently misbehaving.lerd parkno longer registers framework subdirectories as sites — when a project root is accidentally used as a park directory, subdirectories likeapp/,vendor/, andpublic/are now skipped with a warning rather than being registered as phantom sites.
[1.0.0] — 2026-03-25
Added
Laravel Horizon support — lerd auto-detects
laravel/horizonincomposer.jsonand provides dedicatedlerd horizon:start/lerd horizon:stopcommands that runphp artisan horizonas a persistent systemd user service (lerd-horizon-{site}). When Horizon is detected, the Queue toggle in the web UI is replaced by a Horizon toggle, and a Horizon log tab appears in the site detail panel while Horizon is running. Pause/unpause correctly stops and resumes the Horizon service alongside other workers. MCP toolshorizon_startandhorizon_stopprovide the same control to AI assistants.Service dependencies (
depends_on) — custom services can now declare which services they depend on. Starting a service with dependencies starts those dependencies first; starting a dependency automatically starts any services that depend on it; stopping a dependency cascade-stops its dependents first. Declare via thedepends_onYAML field, the--depends-onflag onlerd service add, or thedepends_onparameter in theservice_addMCP tool.lerd man— terminal documentation browser — browse and search the built-in docs without leaving the terminal. Opens an interactive TUI with arrow-key navigation, live filtering by title or content, and a scrollable markdown pager. Pass a page name to jump directly (e.g.lerd man sites). SetGLAMOUR_STYLE=lightto override the default dark theme. Works in non-TTY mode too:lerd man | catprints a table of contents andlerd man sites | catprints raw markdown.lerd about— new command that prints the version, build info, project URL, and copyright.CLI commands auto-start services on paused sites — running
php artisan,composer,lerd db:export,lerd db:import, orlerd db:shellin a paused site's directory automatically starts any services the site needs (MySQL, Redis, etc.) before executing. A notice is printed only when a service actually needs starting; if services are already running the command executes silently. The site stays paused — no vhost restore or worker restart.lerd pause/lerd unpause— pause a site without unlinking it.lerd pausestops all running workers (queue, schedule, reverb, stripe, and any custom workers), replaces the nginx vhost with a static landing page, and auto-stops any services no longer needed by other active sites. The paused state persists acrosslerd start/lerd stopcycles.lerd unpauserestores the vhost, restarts any services the site's.envreferences, and resumes all workers that were running before the pause. The landing page includes a Resume button that calls the lerd API directly so you can unpause from the browser.lerd service pin/lerd service unpin— pin a service so it is never auto-stopped, even when no active sites reference it in their.env. Pinning immediately starts the service if it isn't already running. Unpin to restore normal auto-stop behaviour.MCP
site_pause/site_unpausetools — AI agents can pause and resume sites directly, enabling workflows like "pause all sites except the one I'm working on".MCP
service_pin/service_unpintools — AI agents can pin services to keep them always available.Extra ports on built-in services —
lerd service expose <service> <host:container>publishes an additional host port on any built-in service (mysql, redis, postgres, meilisearch, minio, mailpit). Mappings are persisted in~/.config/lerd/config.yamlunderservices.<name>.extra_portsand applied on every start. The service is restarted automatically if running. Use--removeto delete a mapping. MCP toolservice_exposeprovides the same capability.Reverb nginx WebSocket proxy — when a site uses Laravel Reverb (detected via
composer.jsonorBROADCAST_CONNECTION=reverbin.env), lerd now adds a/applocation block to the nginx vhost that proxies WebSocket upgrade requests to the Reverb server running on port 8080 inside the PHP-FPM container. The block is added automatically onlerd linkand onreverb:start.Framework definitions — user-defined PHP framework YAML files at
~/.config/lerd/frameworks/<name>.yaml. Each definition describes detection rules, the document root, env file format, per-service env detection/variable injection, and background workers.lerd framework list/add/removemanage definitions from the CLI.Framework workers — frameworks can define named background workers (e.g.
messengerfor Symfony,horizonorpulsefor Laravel) that run as systemd user services inside the PHP-FPM container.lerd worker start <name>/lerd worker stop <name>/lerd worker listmanage them.Custom workers for Laravel — the built-in Laravel definition now has built-in
queue,schedule, andreverbworkers. Additional workers (e.g. Horizon, Pulse) can be added vialerd framework add laravel --from-file ...; they are merged on top of the built-in definition.Generic
lerd workercommand —lerd worker start/stop/listworks for any framework-defined worker.lerd queue:start,lerd schedule:start, andlerd reverb:startare now aliases forlerd worker start queue/schedule/reverband work on any framework with those workers, not just Laravel.Web UI: framework worker toggles — custom framework workers appear as indigo toggles in the Sites panel alongside queue/schedule/reverb. Each running worker shows a log tab in the site detail drawer and an indicator dot in the site list.
MCP
worker_start/worker_stop/worker_list— start, stop, or list framework-defined workers for a site via the MCP server.MCP
framework_list/framework_add/framework_remove— manage framework definitions from an AI assistant.framework_addwithname: "laravel"adds custom workers to the built-in Laravel definition.MCP
sitesnow includes framework and workers — each site entry now includes itsframeworkname and aworkersarray with running status per worker.Docs:
Frameworks & Workerspage — full documentation of the YAML schema, detection rules, worker definitions, and complete Symfony and WordPress examples.Web UI: docs link — a "Docs" link in the dashboard navbar opens the documentation site.
Changed
lerd service listuses a compact two-column format — theTypecolumn has been removed. Custom services show[custom]inline after their status. Inactive reason anddepends on:info now appear as indented sub-lines, keeping the output narrow on small terminals.lerd service list/lerd service statusshows inactive reason — when a service is inactive, the output now includes a short note explaining why:(no sites using this service)for auto-stopped services, or(start with: lerd service start <name>)for manually stopped ones.lerd logsaccepts a site name as target — pass a registered site name to get logs for that site's PHP-FPM container (e.g.lerd logs my-project). Previously only nginx, service names, and PHP version strings were accepted.lerd unlinkauto-stops unused services — after unlinking a site, any services that were only needed by that site are automatically stopped (respecting pin and manually-started flags).db:importanddb:exportaccept a-d/--databaseflag — both commands now accept an optional--database/-dflag to target a specific database. When omitted the database name falls back toDB_DATABASEfrom the project's.envas before. The MCPdb_exporttool gains the same optionaldatabaseargument.lerd secure/lerd unsecurerestart the Stripe listener — if alerd stripe:listenservice is active when HTTPS is toggled, it is automatically restarted with the updated forwarding URL so--forward-tostays in sync with the site's scheme.MinIO: per-site bucket created by
lerd env— when MinIO is detected,lerd envnow creates a bucket named after the site handle (e.g.my_project), sets it to public access, and writesAWS_BUCKET=<site>andAWS_URL=http://localhost:9000/<site>into.env. PreviouslyAWS_BUCKETwas hardcoded tolerdandAWS_URLhad no bucket path.reverb:startregenerates the nginx vhost — runninglerd reverb:start(or toggling Reverb in the web UI) now regenerates the site's nginx config and reloads nginx, ensuring the/appWebSocket proxy block is added to existing sites without requiringlerd linkto be re-run.lerd envsets correct Reverb connection values —REVERB_HOST,REVERB_PORT, andREVERB_SCHEMEare now derived from the site's domain and TLS state instead of hardcodedlocalhost:8080.VITE_REVERB_*vars are also written to match.queue_start/schedule_start/reverb_startare no longer Laravel-only — these CLI commands and MCP tools now work for any framework that defines a worker with that name.lerd envrespects framework env configuration — uses the framework's configured env file, example file, format,url_key, and per-service detection rules instead of hardcoded Laravel paths.lerd link/lerd parkdetect and record the framework — the detected framework name is stored in the site registry and shown inlerd sites.
Fixed
lerd phpandlerd artisanno longer break MCP stdio transport — both commands now allocate a TTY (-t) only when stdin is a real terminal. When invoked by MCP or any other pipe-based tool, the TTY flag is omitted so stdin/stdout remain clean byte streams.Reverb toggle no longer appears on projects that don't use Reverb — the UI previously showed the Reverb toggle for all Laravel sites because the built-in worker map always included
reverb. It now gates oncli.SiteUsesReverb()(checks forlaravel/reverbin composer.json orBROADCAST_CONNECTION=reverbin.env).
Removed
internal/laravel/detector.go— replaced by the genericconfig.DetectFramework/config.GetFrameworksystem.
[0.9.1] — 2026-03-22
Added
- MCP
service_envtool — returns the recommended Laravel.envconnection variables for any service (built-in or custom) as a key/value map. Agents can callservice_env(name: "mysql")to inspect connection settings without runningenv_setupor modifying.env. Works for all six built-in services and any custom service registered viaservice_add.
Changed
lerd updatedoes a fresh version check — bypasses the 24-hour update cache and always fetches the latest release tag from GitHub directly. After a successful update the cache is refreshed solerd statusandlerd doctorstop showing a stale "update available" notice.lerd updateignores git-describe suffixes — dev/dirty builds (e.g.v0.9.0-dirty) are now treated as equal to the corresponding release when comparing versions, so locally-built binaries no longer trigger a spurious update prompt.
[0.9.0] — 2026-03-22
Added
lerd doctorcommand — full environment diagnostic. Checks podman, systemd user session, linger, quadlet/data dir writability, config validity, DNS resolution, port 80/443/5300 conflicts, PHP-FPM image presence, and update availability. Reports OK/FAIL/WARN per check with a hint for every failure and a summary line at the end.lerd statusshows watcher and update notice —lerd-watcheris now included in the status output alongside DNS, nginx, and PHP-FPM. A highlighted banner is printed when a newer version is cached.- Background update checker — checks GitHub for a new release once per 24 hours; result is cached to
~/.local/share/lerd/update-check.json. Fetches relevant CHANGELOG sections between the current and latest version. Used bylerd status,lerd doctor, the web UI, and the system tray. - MCP
statustool — returns structured JSON with DNS (ok + tld), nginx (running), PHP-FPM per version (running), and watcher (running). Recommended first call when a site isn't loading. - MCP
doctortool — runs the fulllerd doctordiagnostic and returns the text report. Use when the user reports setup issues or unexpected behaviour. - Watcher structured logging — the watcher package now uses
slogthroughout. SetLERD_DEBUG=1in the environment to enable debug-level output at runtime; watcher is otherwise silent except for WARN/ERROR events. - Web UI: Watcher card — the System tab now shows whether
lerd-watcheris running. When stopped, a Start button appears to restart it without opening a terminal. The card also streams live watcher logs (DNS repair events, fsnotify errors, worktree timeouts) directly in the browser. - Web UI: grouped worker accordions — queue workers, schedule workers, Stripe listeners, and Reverb servers are now grouped into collapsible accordions on the Services tab. Click a group header to expand it; only one group is open at a time. Mobile pill navigation is split into core services + group toggle pills with expandable sub-rows.
- Tray: update badge — the "Check for update..." menu item shows "⬆ Update to vX.Y.Z" when a new version is cached. Per-site workers (queue, schedule, Stripe, Reverb) are no longer listed in the tray services section.
Changed
lerd updateshows changelog and asks for confirmation — before downloading anything,lerd updatenow fetches and prints the CHANGELOG sections for every version between the current and latest release, then promptsUpdate to vX.Y.Z? [y/N]. The update only proceeds on an explicity/yes; pressing Enter or anything else cancels.
Fixed
lerd startnow startslerd-watcher— the watcher service was missing from the start sequence and could only be stopped bylerd quit, never started.lerd startnow includes it alongsidelerd-ui.
[0.8.2] — 2026-03-21
Fixed
- 413 Request Entity Too Large on file uploads — nginx now sets
client_max_body_size 0(unlimited) in thehttpblock, applied to all vhosts.lerd startalso rewritesnginx.confon every start so future config changes take effect without runninglerd install. - MCP
logstarget accepts site domains — site names containing dots (e.g.astrolov.com) were incorrectly matched as PHP version strings, producing invalid container names. The PHP version check now requires the strict pattern\d+\.\d+. - MinIO
AWS_URLset to public endpoint —AWS_URLis nowhttp://localhost:9000(browser-reachable) instead ofhttp://lerd-minio:9000(internal container hostname).AWS_ENDPOINTis unchanged and remains the internal address used by PHP. - Services page no longer blinks — the services list was polling every 5 seconds regardless of which tab was active, and showed a loading spinner on each poll. Polling now only runs while the services tab is visible, and the spinner only shows on the initial load.
Added
- DNS health watcher — the
lerd-watcherdaemon now polls.testDNS resolution every 30 seconds. When resolution breaks, it waits forlerd-dnsto be ready and re-applies the resolver configuration, replicating the repair performed bylerd start. Uses the configured TLD (dns.tldin global config, defaulttest). - MCP
logstarget is optional — whentargetis omitted, logs for the current site's PHP-FPM container are returned (resolved fromLERD_SITE_PATH). Specifytargetonly to view a different service or site.
Changed
make installrespects manually-stopped services —lerd-ui,lerd-watcher, andlerd-trayare only restarted after install if they were already running. Services stopped vialerd quitare left stopped.
[0.8.1] — 2026-03-21
Fixed
- MCP
service_start/service_stopaccept custom services — the MCP tool schema previously restricted thenamefield to an enum of built-in services, causing AI assistants to refuse to call these tools for custom services added viaservice_add. The enum constraint has been removed; any registered service name is now valid.
Changed
- MCP SKILL and guidelines updated —
soketiremoved from the built-in service list (dropped in v0.8.0);service_start/service_stopdescriptions clarified to explicitly mention custom service support.
[0.8.0] — 2026-03-21
Added
lerd reverb:start/reverb:stop— runs the Laravel Reverb WebSocket server as a persistent systemd user service (lerd-reverb-<site>.service), executingphp artisan reverb:startinside the PHP-FPM container. Survives terminal sessions and restarts on failure. Also available aslerd reverb start/lerd reverb stop.lerd schedule:start/schedule:stop— runs the Laravel task scheduler as a persistent systemd user service (lerd-schedule-<site>.service), executingphp artisan schedule:work. Also available aslerd schedule start/lerd schedule stop.lerd dashboard— opens the Lerd dashboard (http://127.0.0.1:7073) in the default browser viaxdg-open.- Auto-configure
REVERB_*env vars —lerd envnow generatesREVERB_APP_ID,REVERB_APP_KEY,REVERB_APP_SECRET, andREVERB_HOST/PORT/SCHEMEvalues whenBROADCAST_CONNECTION=reverbis detected, using random secure values for secrets. lerd setuprunsstorage:link— setup now runsphp artisan storage:linkwhen the site'sstorage/app/publicdirectory is not yet symlinked.lerd setupstarts the queue worker — setup now startsqueue:startas a final step whenQUEUE_CONNECTION=redisis set in.envor.env.example.- Watcher triggers
queue:restarton config changes — the watcher daemon monitors.env,composer.json,composer.lock, and.php-versionin every registered site and signalsphp artisan queue:restartwhen any of those files change (debounced). This ensures queue workers reload after deploys or PHP version changes. lerd start/stopmanage schedule and reverb —lerd startandlerd stopnow include alllerd-schedule-*andlerd-reverb-*service units in their start/stop sequences alongside queue workers and stripe listeners.- MCP tools for reverb, schedule, stripe — new
reverb_start,reverb_stop,schedule_start,schedule_stop, andstripe_listentools exposed via the MCP server. - Web UI: schedule and reverb per-site — the site detail panel shows whether the schedule worker and Reverb server are running, with start/stop buttons and live log streaming.
- Web UI:
stripe:stopaction — the dashboard now supports stopping a stripe listener from the site action menu (was start-only). WriteServiceIfChanged— internal helper that skips writing and runningdaemon-reloadwhen a service unit's content is unchanged, preventing unnecessary Podman quadlet regeneration.QueueRestartForSite— internal function that signals a graceful queue worker restart viaphp artisan queue:restartinside the PHP-FPM container.
Changed
- Queue worker uses
Restart=always— thelerd-queue-*service unit now restarts unconditionally (wasRestart=on-failure), matching the behaviour of schedule and reverb services. lerd.testdashboard vhost removed —lerd installno longer generates an nginx proxy vhost forlerd.test. The dashboard is only accessible athttp://127.0.0.1:7073. Thelerd.testdomain is no longer reserved and may be used for a regular site.- Web UI queue/stripe start is non-blocking —
queue:startandstripe:listensite actions now run in a background goroutine so the HTTP response returns immediately rather than waiting for the service to start.
Removed
- Soketi service removed — Soketi has been removed from Lerd's service list, config defaults, and env suggestions. Laravel Reverb (
lerd reverb:start) is the recommended WebSocket solution.
[0.7.0] — 2026-03-21
Added
lerd quitcommand — fully shuts down Lerd: stops all containers and services (likelerd stop), then also stops thelerd-uiandlerd-watcherprocess units, and kills the system tray.- Start/Stop from the web UI — the dashboard now has Start and Stop buttons that call
lerd start/lerd stopvia new/api/lerd/start,/api/lerd/stop, and/api/lerd/quitAPI endpoints. The Start button is only shown when one or more core services (DNS, nginx, PHP-FPM) are not running. lerd startresumes stripe listeners —lerd-stripe-*services are now included in the start sequence alongside queue workers and the UI service.
Changed
- Tray quit uses
lerd quit— the tray's quit action now calls the newquitcommand instead ofstop, ensuring a full shutdown including the UI and watcher processes. The menu item is renamed from "Stop Lerd & Quit" to "Quit Lerd". lerd stopstops all services regardless of pause state — stop now shuts down all installed services including paused ones and stripe listeners, ensuring a clean shutdown every time.
Fixed
- Log panel guards — clicking to open logs for FPM, nginx, DNS, or queue services no longer attempts to open a log stream when the service is not running.
0.6.0 — 2026-03-21
Added
- Git worktree support — each
git worktreecheckout automatically gets its own subdomain (<branch>.<site>.test) with a dedicated nginx vhost. No manual steps required.- The watcher daemon detects
git worktree add/git worktree removein real time via fsnotify and generates or removes vhosts accordingly. It watches.git/itself so it correctly re-attaches when.git/worktrees/is deleted (last worktree removed) and re-created (new worktree added). - Startup scan generates vhosts for all existing worktrees across all registered sites.
EnsureWorktreeDeps— symlinksvendor/andnode_modules/from the main repo into each worktree checkout, and copies.envwithAPP_URLrewritten to the worktree subdomain.lerd sitesshows worktrees indented under their parent site.- The web UI shows worktrees in the site detail panel with clickable domain links and an open-in-browser button.
- A git-branch icon appears on the site button in the sidebar whenever the site has active worktrees.
- The watcher daemon detects
- HTTPS for worktrees — when a site is secured with
lerd secure, all its worktrees automatically receive an SSL vhost that reuses the parent site's wildcard mkcert certificate (*.domain.test). No separate certificate is needed per worktree. Securing and unsecuring a site also updatesAPP_URLin each worktree's.env. - Catch-all default vhost (
_default.conf) — any.testhostname that does not match a registered site returns HTTP 444 / rejects the TLS handshake, instead of falling through to the first alphabetical vhost. stripe:listenas a background service —lerd stripe:listennow runs the Stripe CLI in a persistent systemd user service (lerd-stripe-<site>.service) rather than a foreground process. It survives terminal sessions and restarts on failure.lerd stripe:listen stoptears it down.- Service pause state —
lerd service stopnow records the service as manually paused.lerd startand autostart on login skip paused services.lerd stop+lerd startrestore the previous state: running services restart, manually stopped services stay stopped. - Queue worker Redis pre-flight —
lerd queue:startchecks thatlerd-redisis running whenQUEUE_CONNECTION=redisis set in.env, and returns a friendly error with instructions rather than failing with a cryptic DNS error from PHP.
Fixed
- Park watcher depth — the filesystem watcher no longer registers projects found in subdirectories of parked directories. Only direct children of a parked directory are eligible for auto-registration.
- Nginx reload ordering for secure/unsecure —
lerd secure/lerd unsecure(and their UI/MCP equivalents) now save the updatedsecuredflag tosites.yamlbefore reloading nginx. Previously a failed nginx reload would leavesites.yamlwith a stalesecuredstate, causing the watcher to regenerate the wrong vhost type on restart. - Tray always restarts on
lerd start— any existing tray process is killed before relaunching, preventing duplicate tray instances after repeatedlerd startcalls. - FPM quadlet skip-write optimisation —
WriteFPMQuadletskips writing and daemon-reloading when the quadlet content is unchanged. Unnecessary daemon-reloads caused Podman's quadlet generator to regenerate all service files, which could briefly disruptlerd-dnsand cause.testresolution failures.
[0.5.16] — 2026-03-20
Fixed
- PHP-FPM image build on restricted Podman — fully qualify all base image names in the Containerfile (
docker.io/library/composer:latest,docker.io/library/php:X.Y-fpm-alpine). Systems without unqualified-search registries configured in/etc/containers/registries.confwould fail with "short-name did not resolve to an alias".
[0.5.15] — 2026-03-20
Fixed
- PHP-FPM image build on Podman — the Containerfile now declares
FROM composer:latest AS composer-binas an explicit stage before copying the composer binary. Podman (unlike Docker) does not auto-pull images referenced only inCOPY --from, causing builds to fail with "no stage or image found with that name". This also affectedlerd updateandlerd php:rebuildin v0.5.14, leaving containers stopped if the build failed after the old image was removed. - Zero-downtime PHP-FPM rebuild —
lerd php:rebuildno longer removes the existing image before building. The running container stays up during the build; only the finalsystemctl restartcauses a brief interruption. Force rebuilds now use--no-cacheinstead ofrmi -f. - UI logs panel — clicking logs for a site whose PHP-FPM container is not running now shows a clean "container is not running" message instead of the raw podman error.
lerd php/lerd artisan— running these when the PHP-FPM container is stopped now returns a friendly error with thesystemctl --user startcommand instead of a raw podman error.lerd updateensures PHP-FPM is running — after applying infrastructure changes,lerd updatenow starts any installed PHP-FPM containers that are not running. Also fixed a cosmetic bug where "skipping rebuild" was printed even when a rebuild had just run.
[0.5.14] — 2026-03-20
Added
LERD_SITE_PATHin MCP config —mcp:injectnow embeds the project path asLERD_SITE_PATHin the injected MCP server config. The MCP server reads this at startup and uses it as the defaultpathforartisan,composer,env_setup,db_export, andsite_link, so AI assistants no longer need to pass an explicit path on every call..ai/mcp/mcp.jsoninjection —mcp:injectnow also writes into.ai/mcp/mcp.json(used by Windsurf and other MCP-compatible tools), in addition to.mcp.jsonand.junie/mcp/mcp.json.
[0.5.10] — 2026-03-20
Fixed
- DNS race on install/update —
lerd install(and by extensionlerd update) now waits up to 15 seconds for thelerd-dnscontainer to be ready before callingConfigureResolver(). Previously,resolvectlwas called immediately after the container restart, causing systemd-resolved to mark127.0.0.1:5300as failed and fall back to the DHCP DNS server, breaking.testresolution untillerd installwas run again manually.
[0.5.8] — 2026-03-20
Fixed
- GoReleaser archive — split amd64 and arm64 into separate archive definitions so
lerd-tray(amd64-only) doesn't cause a binary count mismatch error
[0.5.7] — 2026-03-20
Fixed
- Cross-distro tray compatibility — the main
lerdbinary is now fully static (CGO_ENABLED=0) and carries no shared library dependencies. A separatelerd-traybinary (built with CGO + libappindicator3) is shipped alongside it in the release tarball. At runtimelerd trayexecslerd-tray; if the helper is absent orlibappindicator3.so.1is missing the tray is silently skipped and everything else keeps working. Fixes startup failure on Fedora and other distros where libappindicator3 is not installed by default.
[0.5.6] — 2026-03-19
Added
- Parallel build TUI —
lerd fetchandlerd php:rebuildnow build PHP-FPM images in parallel with a compact spinner UI; press Ctrl+O to toggle per-job output - Service image pull TUI —
lerd service startshows a spinner while pulling the container image if it is not already present - Condensed uninstall output —
lerd uninstalluses the same spinner UI for a cleaner experience
Changed
- Install output —
lerd installuses plain sequential output with a spinner only for the slow image pull and dnsmasq build steps; interactive sudo prompts (mkcert CA, DNS sudoers) are no longer affected by raw terminal mode - mkcert output indented — output from
mkcert -installis indented to align with the surrounding install step lines - Spinner timer hidden when zero — the elapsed timer is omitted from spinner rows that complete in under one second
Fixed
- PHP Containerfile — removed
pdo_sqliteandsqlite3fromdocker-php-ext-install; both are bundled in the PHP Alpine base image and including them caused aCannot find config.m4build error
[0.5.5] — 2026-03-19
Added
lerd php:ext add/remove/list— manage custom PHP extensions per version; extensions are persisted in config and included in every image rebuild- Expanded default FPM image — added
bz2,calendar,dba,ldap,mysqli,pdo_sqlite,sqlite3,soap,shmop,sysvmsg,sysvsem,sysvshm,xsl(viadocker-php-ext-install) plusigbinaryandmongodb(via PECL); the default bundle now covers ~30 extensions for Herd-parity - Composer extension detection —
lerd park/lerd linkreadsext-*keys fromcomposer.jsonand warns if any required extensions are missing from the image, with an actionable hint lerd php:ini [version]— opens the per-version user php.ini in$EDITOR; the file is mounted into the FPM container at/usr/local/etc/php/conf.d/98-lerd-user.iniand created automatically with commented examples on first use
[0.5.4] — 2026-03-19
Added
- Custom services: users can now define arbitrary OCI-based services without recompiling. Config lives at
~/.config/lerd/services/<name>.yaml.lerd service add [file.yaml]— add from a YAML file or inline flags (--name,--image,--port,--env,--env-var,--data-dir,--detect-key,--detect-prefix,--init-exec,--init-container,--dashboard,--description)lerd service remove <name>— stop (if running), remove quadlet and config; data directory preservedlerd service list— shows built-in and custom services with a[custom]type columnlerd service start/stop— works for custom serviceslerd start/lerd stop— includes installed custom serviceslerd env— auto-detects custom services viaenv_detect, appliesenv_vars, runssite_init.execlerd status— includes custom services in the[Services]section- Web UI services tab — shows custom services with start/stop and dashboard link
- System tray — shows custom services (slot pool expanded from 7 to 20)
/placeholders inenv_varsandsite_init.exec— substituted with the project site handle atlerd envtimesite_initYAML block — runs ash -ccommand inside the service container once per project whenlerd envdetects the service (for DB/collection creation, user setup, etc.)dashboardfield on custom services and built-in service responses — shows an "Open" button in the web UI when the service is active; dashboard URLs for built-ins (Mailpit, MinIO, Meilisearch) moved from hardcoded JS to the API response- README simplified — now a slim landing page pointing to the docs site; full documentation at
geodro.github.io/lerd - Docs updated —
docs/usage/services.mdextended with full custom services reference
Fixed
- Custom service data directory is now created automatically before starting (
podmanrefused to mount a non-existent host path) lerd service removenow checks unit status before stopping — skips stop if not running, and aborts removal if stop fails (prevents orphaned running containers)
0.5.3 — 2026-03-19
Fixed
- Tray not restarting after
lerd update:lerd installwas killing the tray withpkillbut only relaunching it whenlerd-tray.servicewas enabled. If the tray was started directly (lerd tray), it was killed and never restarted. Now tracks whether the tray was running before the kill and relaunches it directly when systemd is not managing it.
0.5.2 — 2026-03-19
Fixed
lerd db:createandlerd db:shellwere missing from the binary —cmd/lerd/main.gowas not staged in the v0.5.1 commit
0.5.1 — 2026-03-19
Added
lerd db:create [name]/lerd db create [name]: creates a database and a<name>_testingdatabase in one command. Name resolution: explicit argument →DB_DATABASEfrom.env→ project name (site registry or directory). Reports "already exists" instead of failing when a database is present. Available for both MySQL and PostgreSQL.lerd db:shell/lerd db shell: opens an interactive MySQL (mysql -uroot -plerd) or PostgreSQL (psql -U postgres) shell inside the service container, connecting to the project's database automatically. Replaces the need to runpodman exec --tty lerd-mysql mysql …manually.
Changed
lerd envnow creates a<name>_testingdatabase alongside the main project database when setting up MySQL or PostgreSQL. Both databases report "already exists" if they were previously created.
0.5.0 — 2026-03-19
Added
- System tray applet (
lerd tray): a desktop tray icon for KDE, GNOME (with AppIndicator extension), waybar, and other SNI-compatible environments. The applet detaches from the terminal automatically and pollshttp://127.0.0.1:7073every 5 seconds. Menu includes:- 🟢/🔴 overall running status with per-component nginx and DNS indicators
- Open Dashboard — opens the web UI
- Start / Stop Lerd toggle
- Services section — lists all active services with 🟢/🔴 status; clicking a service starts or stops it
- PHP section — lists all installed PHP versions; current global default is marked ✔; clicking switches the global default via
lerd use - Autostart at login toggle — enables or disables
lerd-autostart.service - Check for update — polls GitHub; if a newer version is found the item changes to "⬆ Update to vX.Y.Z" and clicking opens a terminal with a confirmation prompt before running
lerd update - Stop Lerd & Quit — runs
lerd stopthen exits the tray
--monoflag forlerd tray: defaults totrue(white monochrome icon); pass--mono=falsefor the red colour iconlerd autostart tray enable/disable: registers/removeslerd-tray.serviceas a user systemd unit that starts the tray on graphical loginlerd startstarts the tray: iflerd-tray.serviceis enabled it is started via systemd; otherwise, if no tray process is already running,lerd trayis launched directlymake build-nogui: headless build (CGO_ENABLED=0 -tags nogui) for CI or servers;lerd trayreturns a clear error instead of failing to link
Changed
- Build now requires CGO and
libappindicator3(libappindicator-gtk3on Arch,libappindicator3-devon Debian/Ubuntu,libappindicator-gtk3-develon Fedora). Themake buildtarget setsCGO_ENABLED=1 -tags legacy_appindicatorautomatically. lerd-autostart.servicenow declaresAfter=graphical-session.targetso the tray (which needs a display) is available whenlerd startruns at login.- Web UI update flow: the "Update" button has been removed. When an update is available the UI now shows
vX.Y.Z available — run lerd update in a terminal. The/api/updateendpoint has been removed. This avoids silent failures caused bysudosteps inlerd installthat require a TTY. /api/statusnow includes aphp_defaultfield with the global default PHP version, used by the tray to mark the active version with ✔.
[0.4.3] — 2026-03-19
Fixed
- DNS broken after install on Fedora (and other NM + systemd-resolved systems): the NetworkManager dispatcher script and
ConfigureResolver()were callingresolvectl domain $IFACE ~test, which caused systemd-resolved to mark the interface asDefault Route: no. This meant queries for anything outside.test(i.e. all internet DNS) had no route and were refused. Fixed by also passing~.as a routing domain in both places — the interface now handles.testspecifically via lerd's dnsmasq and remains the default route for all other queries. .testDNS fails after reboot/restart:lerd startwas callingresolvectl dnsto point systemd-resolved at lerd-dns (port 5300) immediately after the container unit became active — but dnsmasq inside the container wasn't ready to accept connections yet. systemd-resolved would try port 5300, fail, mark it as a bad server, and fall back to the upstream DNS for the rest of the session. Fixed by waiting up to 10 seconds for port 5300 to accept TCP connections before callingConfigureResolver().- Clicking a site URL after disabling HTTPS still opened the HTTPS version: the nginx HTTP→HTTPS redirect was a
301(permanent), which browsers cache indefinitely. After disabling HTTPS, the browser would serve the cached redirect instead of hitting the server. Changed to302(temporary) so browsers always check the server, and disabling HTTPS takes effect immediately.
[0.4.2] — 2026-03-19
Changed
lerd setupdetects the correct asset build command frompackage.json: instead of always suggestingnpm run build, the setup step now readsscriptsfrompackage.jsonand picks the first available candidate in priority order:build(Vite / default),production(Laravel Mix),prod. The step label reflects the detected command (e.g.npm run production). If none of the candidates exist, the build step is omitted from the selector.
[0.4.1] — 2026-03-19
Fixed
lerd statusTLS certificate check:certExpirywas passing raw PEM bytes directly tox509.ParseCertificate, which expects DER-encoded bytes. The fix decodes the PEM block first, so certificate expiry is read correctly and sites no longer show "cannot read cert" when the cert file exists and is valid.
[0.4.0] — 2026-03-19
Added
- Xdebug toggle (
lerd xdebug on/off [version]): enables or disables Xdebug per PHP version by rebuilding the FPM image with Xdebug installed and configured (mode=debug,start_with_request=yes,client_host=host.containers.internal, port 9003). The FPM container is restarted automatically.lerd xdebug statusshows enabled/disabled for all installed versions. lerd fetch [version...]: pre-builds PHP FPM images for the specified versions (or all supported: 8.1–8.5) so the firstlerd use <version>is instant. Skips versions whose images already exist.lerd db:import <file.sql>/lerd db:export [-o file]: import or export a SQL dump using the project's.envDB settings. Supports MySQL/MariaDB (lerd-mysql) and PostgreSQL (lerd-postgres). Also available aslerd db import/lerd db export.lerd share [site]: exposes the current site publicly via ngrok or Expose. Auto-detects which tunnel tool is installed; use--ngrokor--exposeto force one. Forwards to the local nginx port with the correctHostheader so nginx routes to the right vhost.lerd setup: interactive project bootstrap command — presents a checkbox list of steps (composer install, npm ci, lerd env, lerd mcp:inject, php artisan migrate, php artisan db:seed, npm run build, lerd secure, lerd open) with smart defaults based on project state.lerd linkalways runs first (mandatory, not in the list) to ensure the site is registered with the correct PHP version before any subsequent step.--all/-aruns everything without prompting (CI-friendly);--skip-openskips opening the browser.
Fixed
- PHP version detection order:
composer.jsonrequire.phpnow takes priority over.php-version, so projects declaring"php": "^8.4"incomposer.jsonautomatically use PHP 8.4 even if a stale.php-versionfile says otherwise. Explicit.lerd.yamloverrides still take top priority. lerd linkpreserves HTTPS: re-linking a site that was already secured now regenerates the SSL vhost (not an HTTP vhost), sohttps://continues to work after a re-link.lerd linkpreservessecuredflag: re-linking no longer resets a secured site tosecured: false.lerd secure/lerd unsecuredirectory name resolution: sites in directories with real TLDs (e.g.astrolov.com) are now resolved correctly by path lookup, so the commands no longer error with "site not found" when the directory name differs from the registered site name.
[0.3.0] — 2026-03-18
Added
lerd envcommand: copies.env.example→.envif missing, detects which services the project uses, applies lerd connection values, starts required services, generatesAPP_KEYif missing, and setsAPP_URLto the registered.testdomainlerd unsecure [name]command: removes the mkcert TLS cert and reverts the site to HTTPlerd secureandlerd unsecurenow automatically updateAPP_URLin the project's.envtohttps://orhttp://respectivelylerd installnow installs a/etc/sudoers.d/lerdrule granting passwordlessresolvectl dns/domain/revert— required for the autostart service which cannot prompt for a sudo password- PHP FPM images now include the
gmpextension - MCP server (
lerd mcp): JSON-RPC 2.0 stdio server exposing lerd as a Model Context Protocol tool provider for AI assistants (Claude Code, JetBrains Junie, and any MCP-compatible client). Tools:artisan,sites,service_start,service_stop,queue_start,queue_stop,logs lerd mcp:inject: writes.mcp.json,.claude/skills/lerd/SKILL.md, and.junie/mcp/mcp.jsoninto a project directory. Merges into existingmcpServersconfigs — other servers (e.g.laravel-boost,herd) are preserved unchanged- UI: queue worker toggle in the Sites tab — amber toggle to start/stop the queue worker per site; spinner while toggling; error text on failure; logs link opens the live log drawer for that worker when running
- UI: Unlink button in the Sites tab — small red-bordered button that confirms, calls
POST /api/sites/{domain}/unlink, and removes the site from the table client-side immediately lerd unlinkparked-site behaviour: unlinking a site under a parked directory now marks it asignoredin the registry instead of removing it, preventing the watcher from re-registering it on next scan. Runninglerd linkin the same directory clears the flag. Non-parked sites are still removed from the registry entirelyGET /api/sitesfilters out ignored sites so they are invisible in the UIqueue:startandqueue:stopare now also available as API actions viaPOST /api/sites/{domain}/queue:startandPOST /api/sites/{domain}/queue:stop, enabling UI and MCP control
Fixed
- DNS
.testrouting now works correctly after autostart:resolvectl revertis called before re-applying per-interface DNS settings so systemd-resolved resets the current server to127.0.0.1:5300; previously, resolved would mark lerd-dns as failed during boot (before it started) then fall back to the upstream DNS for all queries including.test, causing NXDOMAIN on every.testlookup fnm installno longer prints noise to the terminal when a Node version is already installed
Changed
lerd startandlerd stopnow start/stop containers in parallel — startup is noticeably faster on multi-container setupslerd startnow re-applies DNS resolver config on every invocation, ensuring.testrouting is always correct after reboot or network changeslerd parknow skips already-registered sites instead of overwriting them, preserving settings such as TLS status and custom PHP versionlerd installcompletion message now shows bothhttp://lerd.testandhttp://127.0.0.1:7073as fallback- Composer is now stored as
composer.phar; thecomposershim runs it vialerd php - Autostart service now declares
After=network-online.targetand runs at elevated priority (Nice=-10)
[0.2.0] — 2026-03-17
Changed
- UI completely redesigned: dark theme inspired by Laravel.com with near-black background, red accents, and top navbar replacing the sidebar
- Light / Auto / Dark theme toggle added to the navbar; preference persists in localStorage
[0.1.66] — 2026-03-17
Fixed
lerd startnow detects missing PHP FPM images (e.g. afterpodman rmi) and automatically rebuilds them before starting unitslerd statusnow reportsimage missingwith alerd php:rebuild <version>hint instead of just showing the container as not running
[0.1.65] — 2026-03-17
Fixed
- PHP 8.5 FPM image now builds successfully:
opcacheis already compiled into PHP 8.5 sodocker-php-ext-enable opcacheis now a no-op (|| true);apk updateis run beforeapk addto avoid stale index warnings;redisfalls back to building from GitHub source when PECL fails
[0.1.64] — 2026-03-17
Fixed
redisandimagickPHP extensions now fall back to building from GitHub source when the PECL stable release doesn't compile against the current PHP API version (e.g. PHP 8.5) — redis is required so the build fails if both methods fail; imagick remains optional
[0.1.63] — 2026-03-17
Fixed
pecl install redisis now also non-fatal during PHP FPM image builds — theredisextension (likeimagick) doesn't yet compile against PHP 8.5's new API; both extensions are best-effort and the build succeeds regardless
[0.1.62] — 2026-03-17
Fixed
- PHP 8.5 image build no longer fails when the
imagickPECL extension can't compile against the new PHP API — imagick is installed if available, silently skipped otherwise (redis is unaffected)
[0.1.61] — 2026-03-17
Fixed
- Domains are now always lowercased — directory names like
MyAppor custom--domain MyApp.testnow consistently producemyapp.test
[0.1.60] — 2026-03-17
Fixed
- All container volume mounts now include the
:zSELinux relabeling option — on Fedora (and other SELinux-enforcing systems) dnsmasq and nginx containers were unable to read their config files, causing DNS and nginx to fail immediately after install - Home-directory volume mounts (nginx, PHP-FPM) use
--security-opt=label=disableinstead of:zto avoid recursively relabeling the user's home directory
0.1.53 — 2026-03-17
Fixed
lerd installnow configures the system DNS resolver (writes NM dispatcher / appliesresolvectl) only afterlerd-dnsis running — previously applyingresolvectl dns <iface> 127.0.0.1:5300before the dnsmasq container started routed all DNS through a non-existent server, breaking image pulls with "no such host" / "server misbehaving"
0.1.52 — 2026-03-17
Fixed
- DNS resolution on Ubuntu (systemd-resolved + NetworkManager): NM overrides global
resolved.confdrop-ins via DBUS so theDNS=127.0.0.1:5300drop-in had no effect; now installs an NM dispatcher script (/etc/NetworkManager/dispatcher.d/99-lerd-dns) that callsresolvectl dns/domainper-interface on "up", and applies it immediately to the default interface - Upstream DNS servers in the dnsmasq config are now detected from the running system (
/run/systemd/resolve/resolv.conf→/etc/resolv.conf, skipping loopback/stub addresses) — no hardcoded IPs lerd-dns.containernow mounts~/.local/share/lerd/dnsmasqinto the container and uses--conf-dirinstead of embedding all options in theExecline
0.1.51 — 2026-03-17
Fixed
- DNS resolution now works on systems using systemd-resolved (Ubuntu, etc.) —
lerd installdetects whether systemd-resolved is the active resolver and writes/etc/systemd/resolved.conf.d/lerd.confwithDNS=127.0.0.1:5300andDomains=~testinstead of configuring NetworkManager's embedded dnsmasq lerd statusPHP version hint no longer shows "8.5" — corrected to "8.4"
0.1.50 — 2026-03-17
Fixed
install.sh--localbinary path is now validated beforecheck_prerequisitesruns — previously podman not being installed would causedie "podman is required"before the file-exists check, making bats test 23 fail in CI
0.1.49 — 2026-03-17
Fixed
install.shask()no longer causes CI test failures underset -euo pipefailwhen/dev/ttyis unavailable —read </dev/ttynow has2>/dev/null || trueso a missing tty is silently treated as "no"
0.1.48 — 2026-03-17
Fixed
- All container images now use fully qualified names (
docker.io/library/nginx:alpine, etc.) — Ubuntu's/etc/containers/registries.confhas no unqualified-search registries, causing short names to fail with exit code 125 lerd installnow writes thelerd.testUI vhost before starting nginx so the dashboard is available on the very first start
0.1.47 — 2026-03-17
Fixed
lerd installnow runspodman system migrateafter installing podman on a fresh system to initialise Podman's storage before the first rootless container operation
0.1.46 — 2026-03-17
Fixed
- Container images are now pre-pulled before
daemon-reload/ service start so the systemd 90 s default timeout is not exceeded on a fresh install pulling large images;TimeoutStartSec=300added to bothlerd-nginx.containerandlerd-dns.containeras an additional safeguard lerd installno longer prints a spurious nginx reload[WARN]— the separate reload step was removed;RestartUnitalready loads the latest config
0.1.45 — 2026-03-17
Fixed
install.shask()now reads from/dev/ttyso prompts work correctly when the script is piped to bash (curl | bash); a missing tty falls back gracefullyinstall.shnow aborts with a clear error ifpodmanis not found after the prerequisite install step
0.1.44 — 2026-03-17
Fixed
- HTTP→HTTPS redirect in SSL vhosts changed from
301(permanent, browser-cached) to302(temporary) so disabling HTTPS is not cached by the browser - Site domain links in the dashboard now use
https://when TLS is enabled andhttp://otherwise
0.1.43 — 2026-03-17
Fixed
lerd install(andlerd update) no longer overwrites SSL vhosts with plain HTTP configs — sites withsecured: trueinsites.yamlnow have their SSL vhost regenerated in-place during the vhost regeneration step- Sites table in the dashboard no longer flickers on background poll — the 5 s interval now updates existing row properties in-place instead of replacing the entire array; new/removed sites are still added/removed correctly
0.1.42 — 2026-03-17
Added
- Sites tab now auto-refreshes every 5 seconds — PHP version, Node version, TLS status, and FPM running state stay current without a manual reload
- Install Node version UI added to the Services tab — enter a version number and click Install to run
fnm installin the background
0.1.41 — 2026-03-17
Fixed
lerd installnow usesRestartUnit(instead ofStartUnit) for all services so a re-run afterlerd updatepicks up the new binary and any changed quadlet files- Installer bats tests updated:
latest_versionmocks updated for the redirect-based version check,certutiladded to the--checkprerequisite mock
0.1.40 — 2026-03-17
Fixed
- Sites tab now shows the live PHP/Node version detected from disk (
.php-version,.lerd.yaml,composer.json) instead of the stale value stored insites.yaml; if the detected version differs,sites.yamlis updated automatically
0.1.39 — 2026-03-17
Added
- PHP and Node columns in the Sites tab are now dropdowns — selecting a version writes
.php-version/.node-versionto the project directory, updatessites.yaml, regenerates the nginx vhost, and reloads nginx; available PHP versions come from installed FPM quadlets, Node versions fromfnm list
0.1.38 — 2026-03-17
Fixed
- HTTPS sites no longer return "File not found" —
SecureSitewas constructing a bareconfig.Sitewith onlyDomainandPHPVersion, leavingPathempty so the generated SSL vhost hadroot /public; it now receives the full site struct fetchLatestVersiontests updated to use the redirect-based approach (fixes broken test suite after v0.1.34 change)
0.1.37 — 2026-03-17
Fixed
- HTTPS toggle in Sites tab no longer returns "site not found" — the API was looking up sites by name but receiving the full domain; added
FindSiteByDomainand switched the handler to use it - HTTPS column now shows a proper toggle switch instead of "On / Off" text buttons
0.1.36 — 2026-03-17
Fixed
lerd statusno longer warns about all 7 services being inactive — it now only shows services that have a quadlet file on disk (i.e. were intentionally installed); uninstalled services are silently skipped with a single "No services installed" message if none are present
0.1.35 — 2026-03-17
Added
install.shnow checks forcertutil(nss-tools) as a prerequisite and offers to install it automatically — without it mkcert cannot register the CA in Chrome/Firefox, causingERR_CERT_AUTHORITY_INVALIDon HTTPS sites- README documents
certutil/nss-toolsas a requirement with per-distro package names
0.1.34 — 2026-03-17
Fixed
- Version detection in both
lerd updateandinstall.shno longer uses the GitHub REST API — it now follows thehttps://github.com/{repo}/releases/latestHTML redirect to extract the tag from the URL; this endpoint is not rate-limited (60 req/hour limit on the API was causing "No releases found" / HTTP 403 for anyone who ran the installer more than a few times)
0.1.33 — 2026-03-17
Fixed
install.shlatest_version()now sendsUser-Agent: lerd-installerandAccept: application/vnd.github+jsonheaders — GitHub's API returns 403 for unauthenticated requests without a User-Agent, which the script was silently treating as "no releases found"install.shcmd_uninstallnow dynamically discovers units from quadlet files on disk (same fix aslerd uninstall)
0.1.32 — 2026-03-17
Fixed
lerd uninstallnow stops and disables all services that were enabled at runtime (e.g. mailpit, soketi started from the UI dashboard) — the unit list is now derived dynamically from the quadlet files on disk instead of a hardcoded list, so nothing is left behindlerd uninstallnow also removeslerd-ui.servicealongsidelerd-watcher.service
0.1.31 — 2026-03-17
Fixed
lerd updateno longer fails with "GitHub API returned HTTP 403" — the version check now sends aUser-Agent: lerd-cliheader, which GitHub requires for unauthenticated API requests
0.1.30 — 2026-03-17
Fixed
lerd updatenow restarts thelerd-uisystemd service after applying changes so the new binary is immediately picked up without manual intervention
0.1.29 — 2026-03-17
Added
- HTTPS toggle in Sites tab — the TLS column is now a clickable button; clicking it calls
POST /api/sites/{domain}/secureorunsecure, issues/removes the mkcert certificate, regenerates the nginx vhost, and reloads nginx inline without leaving the UI
Fixed
lerd secureno longer fails with "renaming SSL config: no such file or directory" —RemoveVhostwas deleting both the HTTP and SSL config files before the rename; the command now only removes the HTTP config, then renames the SSL one into place.envCopy button now works on plain HTTP (lerd.test) —navigator.clipboard.writeTextrequires HTTPS; added adocument.execCommand('copy')fallback via a temporary off-screen textarea
0.1.28 — 2026-03-17
Added
- Live logs drawer — click any site row in the dashboard to open a live streaming log panel at the bottom of the screen showing that site's PHP-FPM container output (
podman logs -f); lines are colour-coded (red for errors/fatals, yellow for warnings/notices); auto-scrolls with a 500-line buffer; Clear and Close controls in the header - Env vars preview in Services tab — each service card now has a "Show .env / Hide .env" toggle that expands a syntax-highlighted code block with all the
.envvariables for that service, with a one-click Copy button in the header
Fixed
- Service start from UI no longer fails with "Unit not found" after the first time a service quadlet is written —
handleServiceActionnow retriesStartUnitup to 5 times with increasing delays (300 ms each) to give the systemd Quadlet generator time to register the new.serviceunit afterdaemon-reload - Removed stale "Copied to clipboard!" feedback element that was previously separate from the env preview Copy button
0.1.27 — 2026-03-17
Fixed
lerd update(andlerd install) no longer prompts for sudo if DNS is already configured —dns.Setup()now checks whether/etc/NetworkManager/conf.d/lerd.confand/etc/NetworkManager/dnsmasq.d/lerd.confalready contain the correct content and skips all sudo steps if so; this makes updating from the UI dashboard work without any password prompt in the common case
0.1.26 — 2026-03-17
Fixed
lerd.testproxy vhost no longer usesresolver+set $upstream— nginx's resolver directive only works with DNS, buthost.containers.internalis resolved via/etc/hostsinside the container; using a staticproxy_pass http://host.containers.internal:7073lets nginx resolve it correctly at startup
0.1.25 — 2026-03-17
Changed
lerd updateno longer unconditionally rebuilds PHP-FPM images — it now computes a SHA-256 hash of the embedded Containerfile and only rebuilds if the hash differs from the one stored after the last successful build- Hash is stored to
~/.local/share/lerd/php-image-hashafterlerd php:rebuild,lerd use <version>, andlerd park(first build)
0.1.24 — 2026-03-17
Fixed
lerd.testproxy vhost now useshost.containers.internalinstead of the Podman network gateway IP — the gateway IP is typically blocked by the host firewall for connections from containers, whilehost.containers.internalis a Podman built-in that always routes to the host correctly
0.1.23 — 2026-03-17
Fixed
- Dashboard service start now writes the Quadlet file and reloads systemd before calling
systemctl start, fixing "Unit not found" error on first use - Service action errors are now returned as JSON with the error message and last 20 lines of
journalctllogs - Frontend shows a loading spinner while toggling, "Started successfully" / "Stopped" flash on success, and an inline error with expandable logs on failure
0.1.22 — 2026-03-17
Fixed
lerd.testdashboard now reachable: UI server changed to listen on0.0.0.0:7073so nginx (running inside the Podman container) can reach it via the network gateway IPlerd installnow reloads nginx after writing thelerd.testproxy vhost so it takes effect immediately without a manual restartlerd.testis now a reserved domain —lerd parksilently skips any directory that would resolve to it,lerd linkreturns an error if the resolved domain is reserved
0.1.21 — 2026-03-17
Added
- Lerd dashboard — browser UI available at
http://lerd.test, served bylerd serve-uias a persistent systemd user service (lerd-ui.service) - Dashboard shows three tabs: Sites (table with domain links, PHP/Node version, TLS badge, FPM status), Services (start/stop toggles, copy
.envbutton per service), System (DNS, nginx, PHP-FPM health, auto-refreshes every 10 seconds) - Update flow built into the UI: "Check for update" button in sidebar checks GitHub releases; if an update is available shows the version and an "Update" button that runs
lerd update lerd installnow writes and startslerd-ui.serviceand generates thelerd.testnginx reverse proxy vhost; printsDashboard: http://lerd.teston completionlerd start/lerd stopincludelerd-uialongside DNS, nginx, and PHP-FPM
0.1.20 — 2026-03-17
Changed
lerd stopnow also stops all installed services (those with a quadlet file) in addition to DNS, nginx, and PHP-FPMlerd startnow also starts all installed services
0.1.19 — 2026-03-17
Added
lerd php:rebuild— force-removes and rebuilds all installed PHP-FPM images; useful after a Containerfile changelerd updatenow automatically runslerd php:rebuildafterlerd installso PHP-FPM image changes (new extensions, config tweaks) are applied on every update
0.1.18 — 2026-03-17
Added
lerd logs— show PHP-FPM container logs for the current project (auto-detects version)lerd logs -f/--follow— tail logs in real timelerd logs nginx— show nginx container logslerd logs <service>— show logs for any service (e.g.lerd logs mailpit)lerd logs <version>— show logs for a specific PHP-FPM container (e.g.lerd logs 8.5)- PHP-FPM containers now route all PHP errors to stderr (
catch_workers_output,log_errors,error_log=/proc/self/fd/2) so they appear inpodman logs/lerd logs
0.1.17 — 2026-03-17
Added
mailpitservice — local SMTP server with web UI athttp://127.0.0.1:8025; catches all outgoing mail from Laravel appssoketiservice — self-hosted Pusher-compatible WebSocket server for Laravel Echo / broadcasting- PHP 8.5 support —
lerd use 8.5builds and starts the PHP 8.5 FPM container; default PHP version updated to 8.5
0.1.16 — 2026-03-17
Added
lerd php [args...]— runs PHP inside the correct versioned FPM container, detecting version from.php-version/composer.json/ global defaultlerd artisan [args...]— shortcut forlerd php artisan [args]lerd node [args...]— runs Node via fnm with auto-detected versionlerd npm [args...]— runs npm via fnm with auto-detected versionlerd npx [args...]— runs npx via fnm with auto-detected versionlerd installnow writesphp,composer,node,npm,npxshims to~/.local/share/lerd/bin/so commands work directly from the terminal
0.1.15 — 2026-03-17
Fixed
- Service
.envvariables now use container hostnames (lerd-mysql,lerd-redis, etc.) instead of127.0.0.1— PHP-FPM runs inside thelerdPodman network so127.0.0.1resolves to the container's own loopback, not the host
0.1.14 — 2026-03-17
Fixed
- nginx
resolverdirective added tonginx.confusing the Podman network gateway so upstream container hostnames are re-resolved dynamically after FPM restarts (previously nginx cached the old IP and returned 502) fastcgi_passin vhost templates now uses a$fpmvariable to force use of the resolverlerd installnow regenerates all registered site vhosts so template changes are applied immediately- PHP-FPM containers now use a locally built image (
lerd-php{version}-fpm:local) with all Laravel-required extensions pre-installed:pdo_mysql,pdo_pgsql,bcmath,mbstring,xml,zip,gd,intl,opcache,pcntl,exif,sockets,redis,imagick - PHP-FPM images are built automatically on first
lerd use <version>— subsequent runs reuse the cached image
0.1.13 — 2026-03-17
Changed
lerd service start/lerd service restart—.envoutput is printed without leading whitespace for direct copy-paste
0.1.12 — 2026-03-17
Fixed
lerd service start <service>— automatically writes the quadlet file and reloads systemd before starting, so services work on first use without needing a priorlerd install
Changed
lerd service startandlerd service restartnow print the recommended.envvariables to add to your Laravel project after the service starts
0.1.11 — 2026-03-17
Added
lerd start— start DNS, nginx, and all installed PHP-FPM containerslerd stop— stop DNS, nginx, and all installed PHP-FPM containers
0.1.10 — 2026-03-17
Fixed
- Nginx and PHP-FPM containers now mount the user's home directory so project files are accessible inside the containers
nginx.conf— addeduser root;and changed pid/error_log to writable paths (/tmp/nginx.pid, stderr) so nginx starts correctly in rootless Podman withoutUserNS=keep-id- PHP-FPM pool now runs workers as root (
-Rflag +zz-lerd.confoverride) so it can read project files in the home directory ensureFPMQuadlet— always overwrites the quadlet file (previously skipped if it existed, leaving stale configs in place)lerd install— now regenerates all existing PHP-FPM quadlets so config changes are applied without manual deletionEnsureNginxConfig— always overwritesnginx.conf(previously skipped if file existed)
0.1.9 — 2026-03-17
Fixed
lerd-dns.containerquadlet template was embedded from the wrong source directory (internal/podman/quadlets/) — the file still referencedandyshinn/dnsmasqwithNetwork=host, causing the DNS container to fail with "Permission denied on port 53"; updated to the Alpine-based dnsmasq on port 5300 via published portdns.Setup()andensureUnprivilegedPorts()—sudosubprocesses now haveStdin/Stdout/Stderrconnected to the process terminal so password prompts display correctly instead of failing with "a terminal is required"
Added
lerd unpark [directory]— removes a parked directory and unlinks all sites registered from it
Changed
lerd parkandlerd link— directory names with real TLDs (.com,.net,.org,.io,.ltd, etc.) now have the TLD stripped and remaining dots replaced with dashes before appending.test(e.g.admin.astrolov.com→admin-astrolov.test)lerd use <version>/lerd status— PHP version detection now tracks FPM quadlet files instead of static CLI binaries, solerd use 8.4is immediately reflected inlerd status
0.1.8 — 2026-03-17
Fixed
lerd updatenow automatically runslerd installafter swapping the binary, so quadlet files, DNS config, sysctl settings and any other infrastructure changes are applied without the user having to run a second command
0.1.7 — 2026-03-17
Fixed
lerd-dns.container— removedNetwork=hostandAddCapability=NET_ADMINwhich both fail under rootless Podman; container now runs dnsmasq on port 5300 via a published port (127.0.0.1:5300:5300)lerd install— now checksnet.ipv4.ip_unprivileged_port_startand automatically sets it to 80 (with sudo) so rootless Podman can bind nginx to ports 80 and 443; also writes/etc/sysctl.d/99-lerd-ports.confto persist across reboots
Changed
lerd status— every FAIL entry now shows an actionable hint (e.g.systemctl --user start lerd-nginx,lerd service start mysql,lerd use 8.4)
0.1.6 — 2026-03-17
Fixed
lerd installwas callingdns.WriteDnsmasqConfig(writes only the container's local config) instead ofdns.Setup(), which means/etc/NetworkManager/conf.d/lerd.confand/etc/NetworkManager/dnsmasq.d/lerd.confwere never written and NetworkManager was never restarted — causing*.testDNS resolution to silently faildns.Setup()now prints a clear message before invokingsudoso users know why a password prompt appears
0.1.5 — 2026-03-17
Fixed
install.sh— definitively fixed theinstall: cannot stat '...\033[0m...'error by refactoringdownload_binaryto accept a caller-supplied directory instead of returning a path via stdout; all output now goes directly to the terminal (stderr) and is never captured by command substitution
0.1.4 — 2026-03-17
Fixed
install.sh—install: cannot stat '...\033[0m...'error:download_binarywas called inside$()command substitution so itsinfooutput was captured into thebinaryvariable along with the path; all UI output indownload_binarynow goes to stderr, leaving only the path on stdoutinstall.sh— tar extraction errors insidedownload_binarynow also go to stderr and produce a clean error message instead of polluting the captured path
0.1.3 — 2026-03-17
Fixed
install.sh—BASH_SOURCE[0]: unbound variablestill occurred on bash versions where${array[0]:-default}triggersset -uwhen the array itself is unset (not just empty); fixed by suspendingnounsetbriefly withset +ubefore readingBASH_SOURCE
0.1.2 — 2026-03-17
Fixed
install.sh—BASH_SOURCE[0]: unbound variablecrash when the script is piped to bash (curl|bash/wget|bash);BASH_SOURCEis unset in that execution context so it now defaults to$0
0.1.1 — 2026-03-17
Fixed
install.sh— replaced[[ ... ]] && main "$@"guard withif/fiso the script sources cleanly underset -euo pipefail(the&&idiom exits with code 1 when the condition is false, whichset -etreated as fatal)install.sh—latest_versionno longer exits non-zero when the GitHub API returns notag_name(e.g. curl failure or no releases yet)
0.1.0 — 2026-03-17
Initial release.
Added
Core
- Single static Go binary built with Cobra
- XDG-compliant config (
~/.config/lerd/) and data (~/.local/share/lerd/) directories - Global config at
~/.config/lerd/config.yamlwith sensible defaults - Per-project
.lerd.yamloverride support - Linux distro detection (Arch, Debian/Ubuntu, Fedora, openSUSE)
- Build metadata injected at compile time: version, commit SHA, build date
Site management
lerd park [dir]— auto-discover and register all Laravel projects in a directorylerd link [name]— register the current directory as a named sitelerd unlink— remove a site and clean up its vhostlerd sites— tabular view of all registered sites
PHP
lerd install— one-time setup: directories, Podman network, binary downloads, DNS, nginxlerd use <version>— set the global PHP versionlerd isolate <version>— pin PHP version per-project via.php-versionlerd php:list— list installed static PHP binaries- PHP version resolution order:
.php-version→.lerd.yaml→composer.json→ global default
Node
lerd isolate:node <version>— pin Node version per-project via.node-version- Node version resolution order:
.nvmrc→.node-version→package.json engines.node→ global default - fnm bundled for Node version management
TLS
lerd secure [name]— issue a locally-trusted mkcert certificate for a site- Automatic HTTPS vhost generation
- mkcert CA installed into system trust store on
lerd install
Services
lerd service start|stop|restart|status|list— manage optional services- Bundled services: MySQL 8.0, Redis 7, PostgreSQL 16, Meilisearch v1.7, MinIO
Infrastructure
- All containers run rootless on a dedicated
lerdPodman network - Nginx and PHP-FPM as Podman Quadlet containers (auto-managed by systemd)
- dnsmasq container for
.testTLD resolution via NetworkManager - fsnotify-based watcher daemon (
lerd-watcher.service) for auto-discovery of new projects
Diagnostics
lerd status— health overview: DNS, nginx, PHP-FPM containers, services, cert expirylerd dns:check— verify.testresolution
Lifecycle
lerd update— self-update from latest GitHub release (atomic binary swap)lerd uninstall— stop all containers, remove units, binary, PATH entry, optionally data- Shell completion via
lerd completion bash|zsh|fish
Installer (install.sh)
- curl and wget support
- Prerequisite checking with per-distro install prompts (pacman / apt / dnf / zypper)
- Automatic
lerd installinvocation post-download --update,--uninstall,--checkflags- Installs as
lerd-installerfor later use